From 475d84ae5f1d87f2ea4cb3f9d3283347d683e8ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 00:21:36 +0000 Subject: [PATCH] fix(lint): the object publish door refuses a dangling searchableFields / listViews column, measured clean on the shipped corpus (#15495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15254 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` — kept `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived there. An object published clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail silently downstream. Adds `object` to `validateSearchableFields` and `validateListViewFieldRefs` in the reference-integrity suite. No new rule, no new finding class, no severity change — the two members now reach the door where the author is. Measured before crossing at the door's own snapshot shape (`RuntimeStackContext`, per-write, `stack.objects` only, evaluated differentially as `runtime-gate.ts` does) over every shipped object definition under `packages/` and `examples/`: 116 objects, 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. Pins the member surface of the object door by name (the `view` twin's shape), adds the four door controls, and adds the end-to-end refusals through the real `publishPackageDrafts`. `validateSortableFields` measured equally clean and is deliberately NOT crossed — that is its own adjudication. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- ...object-door-searchable-listview-refusal.md | 24 ++++ .../lint/src/reference-integrity-suite.ts | 60 ++++++++- .../src/runtime-gate.object-writes.test.ts | 118 ++++++++++++++++- ...l-publish-drafts-object-field-refs.test.ts | 122 ++++++++++++++++++ 4 files changed, 314 insertions(+), 10 deletions(-) create mode 100644 .changeset/object-door-searchable-listview-refusal.md diff --git a/.changeset/object-door-searchable-listview-refusal.md b/.changeset/object-door-searchable-listview-refusal.md new file mode 100644 index 0000000000..3dd84b8d08 --- /dev/null +++ b/.changeset/object-door-searchable-listview-refusal.md @@ -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. diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 205ab87ce7..731776e238 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -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 — @@ -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 diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index 816efe5545..b58e732d63 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -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, @@ -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 @@ -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: diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts index e0a197b941..1fba370602 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts @@ -294,3 +294,125 @@ describe('publishPackageDrafts refuses a dangling object field-name list (#15254 ).resolves.toBeUndefined(); }); }); + +/** + * #15495 — the SAME door, the two field-name surfaces #15254 left one key over. + * + * That card closed `highlightFields` / `publicSharing.redactFields`, the lists + * the OBJECT carries at its top level. It left the two field-existence rules + * that read an object's SEARCH set and its BUILT-IN LIST VIEWS still declaring + * `runtimeTypes: ['flow', 'view']` — so on the one door a Studio tenant has, + * an object could name `searchableFields: ['gone_field']` or a list-view + * column that resolves to nothing and publish clean, with the identical + * downstream silence: the engine filters a stale search entry out without a + * word (ADR-0061 / `resolveSearchFields`), and a dangling column renders one + * field short. + * + * Both members were measured over the shipped object corpus at the door's own + * snapshot shape before crossing (116 objects, 0 findings — precision 1.0; the + * populations are recorded on the members in `reference-integrity-suite.ts`). + * This block is the end-to-end half: through the REAL `publishPackageDrafts`, + * not through the gate helper. + */ + +/** Step 1 of the click path, on the SEARCH set instead of the highlight strip. */ +const danglingSearchableObject = (name: string) => ({ + name, + label: 'Task', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + health_score: { type: 'number', label: 'Health Score' }, + }, + nameField: 'name', + searchableFields: ['name', 'field_10'], +}); + +/** The same click path landing on a built-in list view's `columns`. */ +const danglingListViewObject = (name: string) => ({ + name, + label: 'Task', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + health_score: { type: 'number', label: 'Health Score' }, + }, + nameField: 'name', + listViews: { + all: { label: 'All Tasks', columns: ['name', 'field_10'] }, + }, +}); + +/** Every name on both surfaces resolving — the control for each refusal above. */ +const cleanBothSurfaces = (name: string) => ({ + ...danglingSearchableObject(name), + searchableFields: ['name'], + listViews: { + all: { label: 'All Tasks', columns: ['name', 'health_score'] }, + }, +}); + +describe('publishPackageDrafts refuses a dangling searchableFields / list-view column (#15495)', () => { + it('REFUSES a stale `searchableFields` entry, naming the rule and the name-keyed path', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await stageObjectDraft(protocol, 'proj_task', danglingSearchableObject('proj_task')); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.outcome).not.toBe('published'); + expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); + + const causal = res.failed.find((f) => f.name === 'proj_task')!; + expect(causal, JSON.stringify(res.failed)).toBeDefined(); + expect(causal.code).toBe('INVALID_METADATA'); + + const wire = JSON.stringify(causal); + expect(wire).toContain('searchable-field-unknown'); + expect(wire).toContain('objects.proj_task.searchableFields[1]'); + expect(wire).toContain('field_10'); + }); + + it('REFUSES a dangling column on a built-in list view, same rule family, same door', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await stageObjectDraft(protocol, 'proj_task', danglingListViewObject('proj_task')); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.outcome).not.toBe('published'); + expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); + + const causal = res.failed.find((f) => f.name === 'proj_task')!; + expect(causal, JSON.stringify(res.failed)).toBeDefined(); + expect(causal.code).toBe('INVALID_METADATA'); + + const wire = JSON.stringify(causal); + expect(wire).toContain('list-view-field-unknown'); + expect(wire).toContain('objects.proj_task.listViews.all.columns[1]'); + expect(wire).toContain('field_10'); + }); + + it('the SAME object publishes once both surfaces resolve — the refusal is about the reference', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await stageObjectDraft(protocol, 'proj_task', cleanBothSurfaces('proj_task')); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.outcome).toBe('published'); + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + expect(res.published.map((p) => p.name)).toEqual(['proj_task']); + }); + + it('a draft save is still NEVER gated (#4463 D1) on either surface', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await expect( + stageObjectDraft(protocol, 'proj_task', danglingSearchableObject('proj_task')), + ).resolves.toBeUndefined(); + await expect( + stageObjectDraft(protocol, 'proj_view_task', danglingListViewObject('proj_view_task')), + ).resolves.toBeUndefined(); + }); +});