Skip to content
26 changes: 26 additions & 0 deletions .changeset/studio-object-field-ref-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/lint": minor
"@objectstack/metadata-protocol": minor
---

A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack.

`list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all.

The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this.

- **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`).
- **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default.
- **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier.
- **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing.

## Migration

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

To fix a dangling reference, 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 list.

`os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them.
31 changes: 30 additions & 1 deletion packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,8 +682,37 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// crossing would be a silent no-op that reads as coverage — the very shape
// #9313 was filed about. Flow snapshots are unchanged: every member keeps
// the default `flow` declaration.
//
// [#15254] `object` joins, and the granularity mechanism above is what
// makes it a NARROW crossing rather than the whole suite arriving at the
// hottest write door: this entry says an `object` write dispatches the
// suite; the suite's per-member `runtimeTypes` says exactly ONE member
// judges that snapshot (`validateObjectFieldRefs`), because it is the
// only member that resolves an object's references against the object's
// OWN field map. Every other member keeps `['flow', 'view']` (or the
// frozen `['flow']` default) and does not run on an object write.
//
// The measured state that forced it: `runtimeAuthoringRulesFor('object')`
// dispatched seven rules and no reference-integrity rule among them, so
// the ONLY door a Studio tenant has ran no reference-integrity judgement
// at all on the artifact Studio actually authors — while the app builder
// mints no `view` items, leaving the members crossed by #9313 with
// nothing to inspect on the click path. That is not a rule that missed a
// case; it is a wall whose fourth door was pointed at a surface the click
// path never produces.
//
// ⚠️ This is a REFUSAL widening on the object door, and unlike #4716 it is
// not fenced by the advisory tier: an object republished with a
// pre-existing dangling `highlightFields` entry is now refused (422)
// rather than warned. That is the acceptance bar of the card, stated in
// its own words ("Warning-level is not enough for the claim; it has to
// refuse"), and it is called out in the changeset's Migration section
// because it turns a publish that used to succeed into one that does not.
// The gate's differential keeps it honest in the one direction that
// matters: a STORED sibling already in violation is never charged to this
// write (#4463 D4).
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow', 'view'],
runtimeTypes: ['flow', 'view', 'object'],
run: (stack, ctx) => validateReferenceIntegrity(stack, ctx),
},
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,22 @@ export type {
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

// [#15254] The object-level half of the same sweep: the field-name LISTS an
// object carries about its own fields (`highlightFields`,
// `publicSharing.redactFields`). `error`, and on the runtime publish door as
// well as the three commands — Studio's app builder mints no `view` items, so
// the list-view members above have nothing to inspect on the only artifacts
// the click path authors, and a dangling `highlightFields` reference produced
// by clicking in the natural order published green.
export {
validateObjectFieldRefs,
OBJECT_FIELD_REF_UNKNOWN,
} from './validate-object-field-refs.js';
export type {
ObjectFieldRefFinding,
ObjectFieldRefSeverity,
} from './validate-object-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
6 changes: 6 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ describe('reference-integrity suite — membership', () => {
// field-naming position the two members above do not own. Placed beside
// them because the three walk the identical rungs.
'validateListViewFieldRefs',
// [#15254] The object-level half of the same sweep: the field-name lists
// an object writes about its OWN fields. Placed beside the list-view
// members because it completes the object's field surface — and because
// Studio's app builder mints no `view` items, which is what left those
// members with nothing to inspect on the click path.
'validateObjectFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
// [#14073] The same page, one question out: the BINDING behind each
Expand Down
36 changes: 36 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateObjectFieldRefs } from './validate-object-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
Expand Down Expand Up @@ -232,6 +233,41 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// 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 },
// [#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.
//
// 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
// nothing to inspect on the only artifacts the click path authors. What it
// authors is the OBJECT. The crossing carries the #9313 property that makes
// it safe — this member resolves only against `stack.objects`, the
// collection the per-write snapshot does carry, so it has no
// missing-collection false-positive channel; and it resolves each name
// against the object's OWN field map, so a one-object snapshot is not
// merely sufficient, it is the whole universe the question has.
//
// It names `flow` because EVERY member of this suite does — the #4463 P1
// surface is the floor the member axis was never meant to narrow, and
// `runtime-gate.view-writes.test.ts` pins it as an invariant over the whole
// roster rather than a preference per member. Worth being plain about what
// it buys here: on a flow snapshot the objects are CONTEXT, present in the
// baseline and the candidate alike, so anything this member could raise
// there cancels in the gate's differential (#4463 D4 — a stored object
// already in violation is never charged to someone else's write). So it
// adds a pass, not a verdict. That is the right trade against being the
// first member to leave the floor.
//
// It does NOT name `view`, and that IS an argued omission: the `crossed`
// list in that same test is written out precisely so each view crossing is
// argued, and this member judges no list view. A view write cannot change
// an object's own field-name lists.
{ name: 'validateObjectFieldRefs', runtimeTypes: ['flow', 'object'], run: validateObjectFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
// [#14073] The same page, one question out. `validatePageFieldBindings`
Expand Down
8 changes: 8 additions & 0 deletions packages/lint/src/runtime-gate.object-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ 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.
'validateReferenceIntegrity',
'lintAutonumberFormats',
'validateSecurityPosture', // #8310 — at this door before #4716
'validateRuleCompilability',
Expand Down Expand Up @@ -240,6 +247,7 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
'validateFunctionalCompleteness',
'validateManagedApiMethods',
'validatePresetComparands',
'validateReferenceIntegrity', // [#15254]
'lintAutonumberFormats',
'validateSecurityPosture',
'validateRuleCompilability',
Expand Down
Loading
Loading