Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/object-door-searchable-listview-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@objectstack/lint": minor
"@objectstack/metadata-protocol": minor
---

The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have.

`#15254` closed this one key over: it crossed the reference-integrity suite onto the object write door for the object's own field-name **lists** (`highlightFields`, `publicSharing.redactFields`). The two members that read the *other* field surfaces an object carries — its ADR-0061 search set and its built-in `listViews` — still declared `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived. An object could publish clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail the same silent way downstream: the engine filters a stale search entry out without a word (`resolveSearchFields`), so `$search` scans a narrower set than declared — or, once every entry is stale, the auto-default set the author never chose — and a dangling column renders one field short.

- **`validateSearchableFields` and `validateListViewFieldRefs` gain `object`** in their suite-member `runtimeTypes`. No new rule and no new finding class: the rule ids (`searchable-field-unknown`, `searchable-field-unsearchable`, `list-view-field-unknown`, `list-view-field-dotted`) and their severities are unchanged — they now reach the door where the author actually is.
- **The crossing carries the #9313 precondition.** Both members resolve only against `stack.objects`, the one collection every per-write snapshot carries, so neither opens a missing-collection false-positive channel; their `views[]` rungs simply find no `stack.views` on an object snapshot.
- **Measured before crossing**, at the door's own snapshot shape and differential, over every shipped object definition in the monorepo: 116 objects (platform-objects 48, showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1, qa 1), 105 built-in list views on 40 objects, 666 list-view field-naming positions and 5 `searchableFields` entries judged — **0 findings for both members, precision 1.0**, against synthetic probes that are refused.
- **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication.

## Migration

**A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `objects.proj_task.searchableFields[1]` or `objects.proj_task.listViews.all.columns[1]` — plus the string that was written and the fields the object actually has.

To fix a refusal, do one of:

- rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or
- drop the entry from the declaration; or, for `searchable-field-unsearchable`, target a text-like stored column instead of a virtual or non-scannable one.

`os validate` / `os build` / `os lint` already reported these findings at the same severity, so a code-authored stack can be repaired before it reaches a publish. Objects that name a platform-injected system column are unaffected — both members resolve those per object and stay silent where the platform really provisions them.
60 changes: 56 additions & 4 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,34 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// crossing has no missing-collection false-positive channel. Measured over
// the shipped view corpus before crossing (0 refusals; population in the
// #9313 PR).
{ name: 'validateSearchableFields', runtimeTypes: ['flow', 'view'], run: validateSearchableFields },
// [#15495] `object` joins the two above — the SAME member, the surface it
// was already reading. Its first rung walks `objects[].searchableFields`
// (the canonical set, ADR-0061) and `objects[].listViews.*.searchableFields`
// (each narrowing of it), and both live on the OBJECT — the artifact
// Studio's app builder actually mints. #15254 crossed the suite ENTRY onto
// the object write door for its object-level sibling; this member's own
// declaration is what makes it judge the snapshot that arrives there, and
// without it the door read an object's `searchableFields` with nothing at
// all, exactly the asymmetry that card closed one key over.
//
// Safe for the #9313 reason, unchanged by the new type: this member resolves
// only against `stack.objects`, the one collection every per-write snapshot
// carries, so it has no missing-collection false-positive channel. Its
// `views[]` rungs simply find no `stack.views` on an object snapshot and
// contribute nothing — a rung that is absent, not a rung that reads dead.
//
// MEASURED before crossing, on the shipped object corpus, through the door's
// own shape (`RuntimeStackContext`, per-write, `stack.objects` only,
// evaluated differentially as `runtime-gate.ts` does): 116 objects — every
// `*.object.ts` under `packages/` and `examples/` (platform-objects 48,
// showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1,
// qa 1) — carrying 5 `searchableFields` entries across the object and
// list-view sets. 0 findings, so 0 false positives: precision 1.0. The zero
// is a fact about the corpus AND the rule: the same sweep's synthetic probe
// (one entry naming a field the object does not have) is refused with
// `searchable-field-unknown` at `error`, and the door controls in
// `runtime-gate.object-writes.test.ts` are that probe made permanent.
{ name: 'validateSearchableFields', runtimeTypes: ['flow', 'view', 'object'], run: validateSearchableFields },
// [#9257] The same reading, one axis over: a list view's `sort` is a field
// name written in metadata, resolved against the object's declared fields. It
// gates (`error`) because the runtime does not tolerate a bad one at all —
Expand All @@ -232,15 +259,40 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// false-positive channel. The standalone list view a Studio tenant or an
// MCP/AI author writes goes through that door and no CLI, so a
// build-time-only rule would never reach the author who made the typo.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
// [#15495] `object` joins it for the same reason and on the same evidence as
// its search sibling above: the FIRST rung this member walks is an object's
// built-in `listViews`, and an object is what the click path authors. The
// three siblings walk identical rungs by construction (the note above this
// member says so), so crossing the field axes without this one would have
// left the object door judging a list view's search set and not its columns.
//
// Same missing-collection argument, and it is this member's own reason for
// crossing onto `view` restated one surface in: it resolves only against
// `stack.objects`. MEASURED over the same 116-object corpus and the same
// per-write differential: 105 built-in list views on 40 objects, 666
// field-naming positions judged (`columns`, filter keys, `grouping`,
// `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
// `fieldOrder` and the kanban / calendar / gantt / timeline / gallery / map
// / tree bindings), 0 findings — precision 1.0 — against a synthetic probe
// column that IS refused (`list-view-field-unknown`, `error`).
//
// `validateSortableFields`, the third sibling, is deliberately NOT crossed
// here: it measured equally clean (0 findings over the same corpus) but its
// crossing is its own adjudication with its own non-vacuity control, and
// this card was scoped to the two axes its title names.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view', 'object'], run: validateListViewFieldRefs },
// [#15254] The same question, one surface IN: the field-name lists the
// OBJECT itself carries (`highlightFields`, `publicSharing.redactFields`),
// which the three list-view members above do not walk because they are not
// on a list view. Placed directly after them because it completes the same
// sweep — every field name an object or its built-in views write down.
//
// `runtimeTypes` is `['flow', 'object']` — the ONLY member of this suite
// that names `object`, and `flow` for the floor the suite keeps.
// `runtimeTypes` is `['flow', 'object']` — the FIRST member of this suite to
// name `object`, and `flow` for the floor the suite keeps. [#15495] It is no
// longer the only one: the two field-existence members above now name it as
// well, each on its own corpus measurement, so `object` is a surface with a
// roster rather than a single crossing (pinned by name in
// `runtime-gate.object-writes.test.ts`).
//
// It names `object` because that is the point of the member: Studio's app
// builder mints no `view` items at all, so the list-view members have
Expand Down
118 changes: 112 additions & 6 deletions packages/lint/src/runtime-gate.object-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
*/
import { describe, expect, it } from 'vitest';
import { AUTHORING_RULES } from './authoring-rules.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import {
runRuntimeAuthoringRules,
runtimeAuthoringRulesFor,
Expand Down Expand Up @@ -113,12 +114,19 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
'validateFunctionalCompleteness',
'validateManagedApiMethods',
'validatePresetComparands', // #8793 — at this door before #4716
// [#15254] The reference-integrity suite, dispatched here so its ONE
// object-judging member runs (`validateObjectFieldRefs`). The entry
// arrives; the suite's per-member `runtimeTypes` decides who judges the
// snapshot, and every other member keeps `['flow','view']` or the frozen
// `['flow']` default. Before this, the only door a Studio tenant has ran
// no reference-integrity rule at all on an object write.
// [#15254] The reference-integrity suite, dispatched here so its
// object-judging members run. The entry arrives; the suite's per-member
// `runtimeTypes` decides who judges the snapshot, and every member that
// does not name `object` keeps `['flow','view']` or the frozen `['flow']`
// default. Before this, the only door a Studio tenant has ran no
// reference-integrity rule at all on an object write.
//
// [#15495] The roster line is UNCHANGED by the two field-existence
// crossings that card added — the entry was already here, and which
// MEMBERS judge an object snapshot is the suite's own finer axis. That
// axis is pinned by name in its own case below, so a member joining or
// leaving the object door is caught by something even though this exact
// list cannot move.
'validateReferenceIntegrity',
'lintAutonumberFormats',
'validateSecurityPosture', // #8310 — at this door before #4716
Expand Down Expand Up @@ -151,6 +159,104 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
}
});

// ── [#15495] The MEMBER surface of the reference-integrity suite ──
//
// The roster case above pins which AUTHORING_RULES entries reach this door.
// The suite is ONE of those entries, so that list cannot say which of its
// members judge an object snapshot — and that is the axis this card moved.
// Written out rather than derived, exactly as the `view` twin in
// `runtime-gate.view-writes.test.ts` is, so a fourth crossing has to be
// argued here instead of arriving by drift.

it('pins the member surface: exactly the crossed members declare `object`', () => {
const crossed = REFERENCE_INTEGRITY_RULES
.filter((r) => (r.runtimeTypes ?? ['flow']).includes('object'))
.map((r) => r.name);
// In registry order. `validateObjectFieldRefs` is #15254's crossing (the
// object's OWN field-name lists); the two above it are this card's, and
// they are the same KIND — a field name written in metadata, resolved
// against `stack.objects`, the one collection every per-write snapshot
// carries, so neither opens a missing-collection false-positive channel.
// Each was measured over the shipped object corpus before crossing (116
// objects, 0 findings, precision 1.0); the comments on the members carry
// the populations, and the four controls below are the non-vacuity half.
expect(crossed).toEqual([
'validateSearchableFields',
'validateListViewFieldRefs',
'validateObjectFieldRefs',
]);
// `validateSortableFields` measured equally clean but was NOT crossed:
// that is its own adjudication, and this pin is where it has to be made.
expect(crossed).not.toContain('validateSortableFields');
// Every crossed member still judges flow snapshots — the #4463 P1 surface
// is not narrowed by the member axis existing.
const offFlow = REFERENCE_INTEGRITY_RULES
.filter((r) => !(r.runtimeTypes ?? ['flow']).includes('flow'))
.map((r) => r.name);
expect(offFlow).toEqual([]);
});

it('REFUSES an object whose searchableFields names a field it does not have', () => {
// The ADR-0061 canonical set, on the object itself. Before this card the
// door read it with nothing: `searchable-field-unknown` existed and was
// `error`, but only a CLI ran it, and a Studio tenant has no CLI.
const result = expectSingleRefusal(
cleanObject({ searchableFields: ['owner', 'gone_field'] }),
'searchable-field-unknown',
);
const f = result.errors.find((e) => e.rule === 'searchable-field-unknown')!;
// Name-keyed on the wire (#10064), and the author reads back what they typed.
expect(f.path).toBe('objects.leave_request.searchableFields[1]');
expect(f.message).toContain('gone_field');
});

it('REFUSES an object whose built-in list view names a column it does not have', () => {
const result = expectSingleRefusal(
cleanObject({ listViews: { all: { label: 'All', columns: ['owner', 'gone_column'] } } }),
'list-view-field-unknown',
);
const f = result.errors.find((e) => e.rule === 'list-view-field-unknown')!;
expect(f.path).toBe('objects.leave_request.listViews.all.columns[1]');
expect(f.message).toContain('gone_column');
});

it('a clean object carrying BOTH declarations publishes — the refusal is about the reference', () => {
// The other half of each control above: the same two keys, every name
// resolving, and the door adds nothing. Without this, "refuses a dangling
// name" and "refuses the key" are indistinguishable.
const result = gateObject(
cleanObject({
fields: { owner: { type: 'text' }, subject: { type: 'text' } },
searchableFields: ['owner', 'subject'],
listViews: { all: { label: 'All', columns: ['owner', 'subject'] } },
}),
);
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
// The suite RAN — the zero is a clean verdict, not a dead gate.
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('a `view` write still dispatches both members exactly as before — the crossing is additive', () => {
// The control the #9313 surface owes this card: adding `object` to a
// member's `runtimeTypes` must not disturb the type it already judged. A
// flattened list overlay is the shape `PUT /api/v1/meta/view` carries.
const overlay = {
name: 'case_backlog',
viewKind: 'list',
data: { provider: 'object', object: 'leave_request' },
columns: ['owner', 'gone_column'],
searchableFields: ['owner', 'gone_field'],
};
const result = runRuntimeAuthoringRules({
type: 'view',
item: overlay,
context: { objects: STORED },
});
const rules = result.errors.map((e) => e.rule);
expect(rules, JSON.stringify(result.errors)).toContain('list-view-field-unknown');
expect(rules, JSON.stringify(result.errors)).toContain('searchable-field-unknown');
});

// ── The six refusal controls — the exemption's non-vacuity evidence ──
//
// Each body is one a tenant could save through Studio/REST/MCP today:
Expand Down
Loading
Loading