Skip to content

Commit b4b37e5

Browse files
claude[bot]claude
andauthored
fix(lint): the object publish door refuses a dangling searchableFields / listViews column, measured clean on the shipped corpus (#15495) (#15637)
#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. Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 52b59d6 commit b4b37e5

4 files changed

Lines changed: 314 additions & 10 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
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.
7+
8+
`#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.
9+
10+
- **`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.
11+
- **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.
12+
- **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.
13+
- **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication.
14+
15+
## Migration
16+
17+
**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.
18+
19+
To fix a refusal, do one of:
20+
21+
- 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
22+
- 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.
23+
24+
`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.

packages/lint/src/reference-integrity-suite.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,34 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
205205
// crossing has no missing-collection false-positive channel. Measured over
206206
// the shipped view corpus before crossing (0 refusals; population in the
207207
// #9313 PR).
208-
{ name: 'validateSearchableFields', runtimeTypes: ['flow', 'view'], run: validateSearchableFields },
208+
// [#15495] `object` joins the two above — the SAME member, the surface it
209+
// was already reading. Its first rung walks `objects[].searchableFields`
210+
// (the canonical set, ADR-0061) and `objects[].listViews.*.searchableFields`
211+
// (each narrowing of it), and both live on the OBJECT — the artifact
212+
// Studio's app builder actually mints. #15254 crossed the suite ENTRY onto
213+
// the object write door for its object-level sibling; this member's own
214+
// declaration is what makes it judge the snapshot that arrives there, and
215+
// without it the door read an object's `searchableFields` with nothing at
216+
// all, exactly the asymmetry that card closed one key over.
217+
//
218+
// Safe for the #9313 reason, unchanged by the new type: this member resolves
219+
// only against `stack.objects`, the one collection every per-write snapshot
220+
// carries, so it has no missing-collection false-positive channel. Its
221+
// `views[]` rungs simply find no `stack.views` on an object snapshot and
222+
// contribute nothing — a rung that is absent, not a rung that reads dead.
223+
//
224+
// MEASURED before crossing, on the shipped object corpus, through the door's
225+
// own shape (`RuntimeStackContext`, per-write, `stack.objects` only,
226+
// evaluated differentially as `runtime-gate.ts` does): 116 objects — every
227+
// `*.object.ts` under `packages/` and `examples/` (platform-objects 48,
228+
// showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1,
229+
// qa 1) — carrying 5 `searchableFields` entries across the object and
230+
// list-view sets. 0 findings, so 0 false positives: precision 1.0. The zero
231+
// is a fact about the corpus AND the rule: the same sweep's synthetic probe
232+
// (one entry naming a field the object does not have) is refused with
233+
// `searchable-field-unknown` at `error`, and the door controls in
234+
// `runtime-gate.object-writes.test.ts` are that probe made permanent.
235+
{ name: 'validateSearchableFields', runtimeTypes: ['flow', 'view', 'object'], run: validateSearchableFields },
209236
// [#9257] The same reading, one axis over: a list view's `sort` is a field
210237
// name written in metadata, resolved against the object's declared fields. It
211238
// gates (`error`) because the runtime does not tolerate a bad one at all —
@@ -232,15 +259,40 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
232259
// false-positive channel. The standalone list view a Studio tenant or an
233260
// MCP/AI author writes goes through that door and no CLI, so a
234261
// build-time-only rule would never reach the author who made the typo.
235-
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
262+
// [#15495] `object` joins it for the same reason and on the same evidence as
263+
// its search sibling above: the FIRST rung this member walks is an object's
264+
// built-in `listViews`, and an object is what the click path authors. The
265+
// three siblings walk identical rungs by construction (the note above this
266+
// member says so), so crossing the field axes without this one would have
267+
// left the object door judging a list view's search set and not its columns.
268+
//
269+
// Same missing-collection argument, and it is this member's own reason for
270+
// crossing onto `view` restated one surface in: it resolves only against
271+
// `stack.objects`. MEASURED over the same 116-object corpus and the same
272+
// per-write differential: 105 built-in list views on 40 objects, 666
273+
// field-naming positions judged (`columns`, filter keys, `grouping`,
274+
// `rowColor`, `userFilters`, `filterableFields`, `hiddenFields`,
275+
// `fieldOrder` and the kanban / calendar / gantt / timeline / gallery / map
276+
// / tree bindings), 0 findings — precision 1.0 — against a synthetic probe
277+
// column that IS refused (`list-view-field-unknown`, `error`).
278+
//
279+
// `validateSortableFields`, the third sibling, is deliberately NOT crossed
280+
// here: it measured equally clean (0 findings over the same corpus) but its
281+
// crossing is its own adjudication with its own non-vacuity control, and
282+
// this card was scoped to the two axes its title names.
283+
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view', 'object'], run: validateListViewFieldRefs },
236284
// [#15254] The same question, one surface IN: the field-name lists the
237285
// OBJECT itself carries (`highlightFields`, `publicSharing.redactFields`),
238286
// which the three list-view members above do not walk because they are not
239287
// on a list view. Placed directly after them because it completes the same
240288
// sweep — every field name an object or its built-in views write down.
241289
//
242-
// `runtimeTypes` is `['flow', 'object']` — the ONLY member of this suite
243-
// that names `object`, and `flow` for the floor the suite keeps.
290+
// `runtimeTypes` is `['flow', 'object']` — the FIRST member of this suite to
291+
// name `object`, and `flow` for the floor the suite keeps. [#15495] It is no
292+
// longer the only one: the two field-existence members above now name it as
293+
// well, each on its own corpus measurement, so `object` is a surface with a
294+
// roster rather than a single crossing (pinned by name in
295+
// `runtime-gate.object-writes.test.ts`).
244296
//
245297
// It names `object` because that is the point of the member: Studio's app
246298
// builder mints no `view` items at all, so the list-view members have

packages/lint/src/runtime-gate.object-writes.test.ts

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
*/
3434
import { describe, expect, it } from 'vitest';
3535
import { AUTHORING_RULES } from './authoring-rules.js';
36+
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
3637
import {
3738
runRuntimeAuthoringRules,
3839
runtimeAuthoringRulesFor,
@@ -113,12 +114,19 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
113114
'validateFunctionalCompleteness',
114115
'validateManagedApiMethods',
115116
'validatePresetComparands', // #8793 — at this door before #4716
116-
// [#15254] The reference-integrity suite, dispatched here so its ONE
117-
// object-judging member runs (`validateObjectFieldRefs`). The entry
118-
// arrives; the suite's per-member `runtimeTypes` decides who judges the
119-
// snapshot, and every other member keeps `['flow','view']` or the frozen
120-
// `['flow']` default. Before this, the only door a Studio tenant has ran
121-
// no reference-integrity rule at all on an object write.
117+
// [#15254] The reference-integrity suite, dispatched here so its
118+
// object-judging members run. The entry arrives; the suite's per-member
119+
// `runtimeTypes` decides who judges the snapshot, and every member that
120+
// does not name `object` keeps `['flow','view']` or the frozen `['flow']`
121+
// default. Before this, the only door a Studio tenant has ran no
122+
// reference-integrity rule at all on an object write.
123+
//
124+
// [#15495] The roster line is UNCHANGED by the two field-existence
125+
// crossings that card added — the entry was already here, and which
126+
// MEMBERS judge an object snapshot is the suite's own finer axis. That
127+
// axis is pinned by name in its own case below, so a member joining or
128+
// leaving the object door is caught by something even though this exact
129+
// list cannot move.
122130
'validateReferenceIntegrity',
123131
'lintAutonumberFormats',
124132
'validateSecurityPosture', // #8310 — at this door before #4716
@@ -151,6 +159,104 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
151159
}
152160
});
153161

162+
// ── [#15495] The MEMBER surface of the reference-integrity suite ──
163+
//
164+
// The roster case above pins which AUTHORING_RULES entries reach this door.
165+
// The suite is ONE of those entries, so that list cannot say which of its
166+
// members judge an object snapshot — and that is the axis this card moved.
167+
// Written out rather than derived, exactly as the `view` twin in
168+
// `runtime-gate.view-writes.test.ts` is, so a fourth crossing has to be
169+
// argued here instead of arriving by drift.
170+
171+
it('pins the member surface: exactly the crossed members declare `object`', () => {
172+
const crossed = REFERENCE_INTEGRITY_RULES
173+
.filter((r) => (r.runtimeTypes ?? ['flow']).includes('object'))
174+
.map((r) => r.name);
175+
// In registry order. `validateObjectFieldRefs` is #15254's crossing (the
176+
// object's OWN field-name lists); the two above it are this card's, and
177+
// they are the same KIND — a field name written in metadata, resolved
178+
// against `stack.objects`, the one collection every per-write snapshot
179+
// carries, so neither opens a missing-collection false-positive channel.
180+
// Each was measured over the shipped object corpus before crossing (116
181+
// objects, 0 findings, precision 1.0); the comments on the members carry
182+
// the populations, and the four controls below are the non-vacuity half.
183+
expect(crossed).toEqual([
184+
'validateSearchableFields',
185+
'validateListViewFieldRefs',
186+
'validateObjectFieldRefs',
187+
]);
188+
// `validateSortableFields` measured equally clean but was NOT crossed:
189+
// that is its own adjudication, and this pin is where it has to be made.
190+
expect(crossed).not.toContain('validateSortableFields');
191+
// Every crossed member still judges flow snapshots — the #4463 P1 surface
192+
// is not narrowed by the member axis existing.
193+
const offFlow = REFERENCE_INTEGRITY_RULES
194+
.filter((r) => !(r.runtimeTypes ?? ['flow']).includes('flow'))
195+
.map((r) => r.name);
196+
expect(offFlow).toEqual([]);
197+
});
198+
199+
it('REFUSES an object whose searchableFields names a field it does not have', () => {
200+
// The ADR-0061 canonical set, on the object itself. Before this card the
201+
// door read it with nothing: `searchable-field-unknown` existed and was
202+
// `error`, but only a CLI ran it, and a Studio tenant has no CLI.
203+
const result = expectSingleRefusal(
204+
cleanObject({ searchableFields: ['owner', 'gone_field'] }),
205+
'searchable-field-unknown',
206+
);
207+
const f = result.errors.find((e) => e.rule === 'searchable-field-unknown')!;
208+
// Name-keyed on the wire (#10064), and the author reads back what they typed.
209+
expect(f.path).toBe('objects.leave_request.searchableFields[1]');
210+
expect(f.message).toContain('gone_field');
211+
});
212+
213+
it('REFUSES an object whose built-in list view names a column it does not have', () => {
214+
const result = expectSingleRefusal(
215+
cleanObject({ listViews: { all: { label: 'All', columns: ['owner', 'gone_column'] } } }),
216+
'list-view-field-unknown',
217+
);
218+
const f = result.errors.find((e) => e.rule === 'list-view-field-unknown')!;
219+
expect(f.path).toBe('objects.leave_request.listViews.all.columns[1]');
220+
expect(f.message).toContain('gone_column');
221+
});
222+
223+
it('a clean object carrying BOTH declarations publishes — the refusal is about the reference', () => {
224+
// The other half of each control above: the same two keys, every name
225+
// resolving, and the door adds nothing. Without this, "refuses a dangling
226+
// name" and "refuses the key" are indistinguishable.
227+
const result = gateObject(
228+
cleanObject({
229+
fields: { owner: { type: 'text' }, subject: { type: 'text' } },
230+
searchableFields: ['owner', 'subject'],
231+
listViews: { all: { label: 'All', columns: ['owner', 'subject'] } },
232+
}),
233+
);
234+
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
235+
// The suite RAN — the zero is a clean verdict, not a dead gate.
236+
expect(result.rulesRun).toContain('validateReferenceIntegrity');
237+
});
238+
239+
it('a `view` write still dispatches both members exactly as before — the crossing is additive', () => {
240+
// The control the #9313 surface owes this card: adding `object` to a
241+
// member's `runtimeTypes` must not disturb the type it already judged. A
242+
// flattened list overlay is the shape `PUT /api/v1/meta/view` carries.
243+
const overlay = {
244+
name: 'case_backlog',
245+
viewKind: 'list',
246+
data: { provider: 'object', object: 'leave_request' },
247+
columns: ['owner', 'gone_column'],
248+
searchableFields: ['owner', 'gone_field'],
249+
};
250+
const result = runRuntimeAuthoringRules({
251+
type: 'view',
252+
item: overlay,
253+
context: { objects: STORED },
254+
});
255+
const rules = result.errors.map((e) => e.rule);
256+
expect(rules, JSON.stringify(result.errors)).toContain('list-view-field-unknown');
257+
expect(rules, JSON.stringify(result.errors)).toContain('searchable-field-unknown');
258+
});
259+
154260
// ── The six refusal controls — the exemption's non-vacuity evidence ──
155261
//
156262
// Each body is one a tenant could save through Studio/REST/MCP today:

0 commit comments

Comments
 (0)