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
35 changes: 35 additions & 0 deletions .changeset/view-door-list-view-field-rules.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": minor
---

feat(lint): the two list-view field rules reach a standalone list view at the runtime publish gate — `view` writes are now judged by `validateSearchableFields` and `validateSortableFields` (#9313)

An `active`-state `view` save through `saveMetaItem` (Studio, REST `/meta` item
CRUD, an MCP/AI author) is now refused with the existing 422 `invalid_metadata`
envelope when its list view declares a `sort` or `searchableFields` entry the
bound object cannot honor — an unknown field name, a virtual (`formula`) sort
target with no stored column to ORDER BY, or a search narrowing the #4254
ingress gate would refuse on every toolbar search. Both rules already gated
`os validate` / `os build` / `os lint`; the runtime door — the only door a
Studio tenant or an MCP/AI author has — ran neither, and an author writing the
exact declaration these rules exist to refuse got it accepted.

Two halves, because either alone is a silent no-op: the reference-integrity
suite's registry entry gains `runtimeTypes: ['view']`, and both rules' metadata
walks gain the SELF rung — a `views[]` entry that IS a flattened standalone
list overlay (`ViewMetadataSchema`'s list-overlay member: `viewKind: 'list'`,
no nested `config`), the shape a standalone list view takes on the wire and the
shape the gate snapshots as `views: [item]`.

The suite dispatches per member on this door: a `view` snapshot reaches exactly
the two list-view field rules (`ReferenceIntegrityRule.runtimeTypes`, default
`['flow']`), never the members whose resolution universe the per-write snapshot
does not carry — `validateActionNameRefs` resolving against `stack.actions`
would otherwise refuse legitimate view writes. CLI behaviour is unchanged (the
commands run the full suite as before); `flow` snapshots keep every member.
Measured before crossing: 0 refusals and 0 advisories over 50 shipped
view-door bodies (11 containers + 39 console-shaped personalization overlays,
`sort[].id` decorations included) across four authoring lineages — a lower
bound, as every authored corpus is. Draft saves are untouched (D1), stored rows
keep being served (ADR-0087 asymmetry), and
`OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to a loud log.
6 changes: 5 additions & 1 deletion packages/lint/src/authoring-rule-wiring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -501,9 +501,13 @@ describe('authoring-rule registry wiring (#4409)', () => {
expect(wired.map((e) => e.name)).toEqual(FAMILY);
// `view` writes also dispatch `validatePresetComparands` (#8793) — a
// different judgement (filter comparands, not predicates), registered
// ahead of the family, so it is named here rather than folded into it.
// ahead of the family — and `validateReferenceIntegrity` (#9313), whose
// suite runs exactly its two list-view FIELD members on a view snapshot
// (`runtime-gate.view-writes.test.ts` pins that member surface). Both
// are named here rather than folded into the family.
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([
'validatePresetComparands',
'validateReferenceIntegrity',
...FAMILY,
]);
});
Expand Down
39 changes: 37 additions & 2 deletions packages/lint/src/authoring-rules.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -268,6 +268,19 @@ export type AuthoringRuleInputTier = 'normalized' | 'parsed';
export interface AuthoringRuleContext {
/** ADR-0080 SDUI component manifest, when the project ships one. */
sduiManifest?: unknown;
/**
* [#9313] The singular metadata type of the per-write snapshot being judged
* — set by the runtime publish gate (`runtime-gate.ts`) on every gated
* write, ABSENT on the three CLI commands (`runAuthoringRules` never sets
* it). Exists for the one entry that is itself a registry: the
* reference-integrity suite dispatches its MEMBERS by this
* (`ReferenceIntegrityRule.runtimeTypes`), because the entry-level
* `runtimeTypes` can only say which writes reach the suite, not which
* members can judge a partial per-write snapshot without inventing
* findings. No other rule reads it, and none should without the same
* argument.
*/
runtimeWriteType?: string;
}

export interface AuthoringRule {
Expand DownExpand Up@@ -640,9 +653,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// collection in the snapshot and return nothing, and the two that would
// load `typescript` need a hook/action/react body the snapshot never
// carries — which is what `runtime-lazy-deps.test.ts` pins.
//
// [#9313] `view` joins, and the granularity decision #4463 P2 reserved is
// taken HERE, in writing: the entry-level `runtimeTypes` says which WRITES
// dispatch the suite, and the suite's own per-member `runtimeTypes`
// (`ReferenceIntegrityRule`, default `['flow']`) says which MEMBERS judge
// that snapshot — the gate passes the written type through
// `ctx.runtimeWriteType`. A `view` write therefore reaches exactly the two
// members that judge a list view's field references and resolve only
// against the `objects` collection the snapshot carries
// (`validateSearchableFields`, `validateSortableFields`). The whole suite
// is NOT the right granularity for this door, measured not assumed, and
// the crossing fails differently per body shape — both ways wrong:
// `validateActionNameRefs` (error-tier) resolves `views[].list` /
// `views[].listViews.*` action names against `stack.actions`, a collection
// no per-write snapshot carries, so on a CONTAINER view write it would
// refuse every stack-level action the body names (measured:
// `action-name-undefined` on the snapshot shape, clean on the full stack)
// — RUNTIME_NEEDS_FULL_SNAPSHOT's exact sentence, on the hottest write
// type the gate has. On a FLATTENED overlay it has no rung at all, so the
// 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.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow'],
run: (stack) => validateReferenceIntegrity(stack),
runtimeTypes: ['flow', 'view'],
run: (stack, ctx) => validateReferenceIntegrity(stack, ctx),
},
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
// is `z.record(z.string(), z.unknown())` and ADR-0089 D3a strictness does not
Expand Down
94 changes: 90 additions & 4 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,19 @@
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
*
* ## The runtime-publish axis (#9313)
*
* The suite is one `AUTHORING_RULES` entry, and the runtime publish gate
* dispatches that entry by the written item's type (`runtimeTypes` on the
* entry: `flow` since #4463 P1, `view` since #9313). Which MEMBERS judge a
* given per-write snapshot is the suite's own, finer axis —
* `ReferenceIntegrityRule.runtimeTypes`, default `['flow']` — because the
* snapshot deliberately carries only the measured context collections
* (objects / permissions / books / datasets), and a member resolving against
* any other collection would read every reference into it as dead. The CLI
* commands ignore the axis entirely: a whole-stack run is always the full
* suite.
*
* ## Known remaining asymmetry
*
* `os doctor` runs only `validateWidgetBindings` and is NOT converted here: it
Expand DownExpand Up@@ -108,25 +121,68 @@ export interface ReferenceIntegrityFinding {
/** One member of the suite. `name` is the exported function's name — the id a wiring test can assert on. */
export interface ReferenceIntegrityRule {
name: string;
/**
* [#9313] The runtime-publish per-write snapshot types this member judges.
*
* The suite is ONE entry in `AUTHORING_RULES`, and that entry's
* `runtimeTypes` says which WRITES dispatch the suite at the runtime publish
* gate. This field is the finer axis the entry cannot express: which MEMBERS
* are safe to judge that per-write snapshot. The two axes differ because the
* snapshot is partial by design (`RuntimeStackContext` carries objects /
* permissions / books / datasets and nothing else): a member that resolves
* against a collection the snapshot does not carry would not go quiet — it
* would report every reference into that collection as dead. Measured on the
* `view` widening: `validateActionNameRefs` resolves the action names in
* `views[].list` / `views[].listViews.*` against `stack.actions`, which no
* per-write snapshot carries, so crossing it with the suite would refuse a
* legitimate CONTAINER view write for every stack-level action it names —
* a false 422 on the only door a Studio tenant has (on a FLATTENED overlay
* it has no rung, so the same crossing would be a silent no-op instead;
* measured both ways, `runtime-gate.view-writes.test.ts`).
*
* ABSENT = `['flow']`, the surface the whole suite has run on since #4463 P1.
* The default is deliberately the frozen historical surface, never "all":
* widening a member onto another type is an explicit declaration here plus
* its own false-positive measurement (#4716's budget), exactly the
* discipline `runtimeTypes` gives registry entries. CLI commands ignore this
* field entirely — all members always run there (see
* {@link validateReferenceIntegrity}).
*/
runtimeTypes?: readonly string[];
run: (stack: Record<string, unknown>) => ReferenceIntegrityFinding[];
}

/** The runtime snapshot types a member judges when it declares none. */
const DEFAULT_MEMBER_RUNTIME_TYPES: readonly string[] = ['flow'];

/**
* Every reference-integrity rule, in the order their findings are reported.
*
* ADDING A RULE: append it here and it runs on `validate`, `lint` and
* `compile` at once. Do not re-wire the commands.
* `compile` at once. Do not re-wire the commands. It joins the runtime
* publish gate on the DEFAULT member surface (`flow` snapshots only, #9313) —
* widening it to another write type is a `runtimeTypes` declaration on the
* member plus that type's own false-positive measurement, never automatic.
*/
export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
{ name: 'validateObjectReferences', run: validateObjectReferences },
{ name: 'validateSearchableFields', run: validateSearchableFields },
// [#9313] `runtimeTypes` gains `view` on this member and its sort sibling:
// both judge a LIST VIEW's field references, and a standalone list view is
// written through `PUT /api/v1/meta/view` — the only door a Studio tenant or
// an MCP/AI author has. Their walks read the flattened overlay shape that
// door carries (see each rule's `views[]` self rung), and they resolve only
// against `stack.objects`, which the per-write snapshot DOES carry — so the
// 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 },
// [#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 —
// `assertSortFieldsExist` (#6994) and `assertOrderByIsMaterializable` (#7095)
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', run: validateSortableFields },
{ name: 'validateSortableFields', runtimeTypes: ['flow', 'view'], run: validateSortableFields },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand DownExpand Up@@ -233,14 +289,44 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
{ name: 'validateReactPageProps', run: validateReactPageProps },
];

/**
* Options for {@link validateReferenceIntegrity}.
*
* Declared as the suite's own type rather than importing
* `AuthoringRuleContext` from `authoring-rules.ts` — the suite predates the
* registry and the registry imports the suite, so the dependency must keep
* pointing that way. The registry's context is assignable to this shape by
* construction (`runtimeWriteType` spells the same key on both).
*/
export interface ReferenceIntegrityRunOptions {
/**
* [#9313] The singular metadata type of the per-write snapshot being judged,
* when the caller is the runtime publish gate. Set by `runtime-gate.ts` for
* every gated write; ABSENT on the three CLI commands and every whole-stack
* caller, which run all members unconditionally.
*/
runtimeWriteType?: string;
}

/**
* Run every reference-integrity rule over a stack. Returns the concatenated
* findings (empty = clean). Pure: no I/O, safe on both the schema-parsed stack
* and the raw/normalized config the `lint` path carries.
*
* [#9313] When `options.runtimeWriteType` is set — the runtime publish gate
* judging one write's snapshot — only the members declaring that type run
* (see {@link ReferenceIntegrityRule.runtimeTypes}). Whole-stack callers pass
* no options and keep the full suite, byte-identically.
*/
export function validateReferenceIntegrity(stack: Record<string, unknown>): ReferenceIntegrityFinding[] {
export function validateReferenceIntegrity(
stack: Record<string, unknown>,
options?: ReferenceIntegrityRunOptions,
): ReferenceIntegrityFinding[] {
const findings: ReferenceIntegrityFinding[] = [];
const writeType = options?.runtimeWriteType;
for (const rule of REFERENCE_INTEGRITY_RULES) {
if (writeType !== undefined
&& !(rule.runtimeTypes ?? DEFAULT_MEMBER_RUNTIME_TYPES).includes(writeType)) continue;
findings.push(...rule.run(stack));
}
return findings;
Expand Down
7 changes: 7 additions & 0 deletions packages/lint/src/runtime-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -326,6 +326,11 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
// family and dispatched for `view` because list-view filter rules are
// one of the three shapes it judges.
'validatePresetComparands',
// #9313 — the reference-integrity suite's entry dispatches for `view`;
// the suite then runs only its two list-view field members
// (`validateSearchableFields` / `validateSortableFields`) on the
// per-write snapshot — see `runtime-gate.view-writes.test.ts`.
'validateReferenceIntegrity',
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand DownExpand Up@@ -485,6 +490,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
// "clean" and "nothing ran" must stay distinguishable.
expect(result.rulesRun).toEqual([
'validatePresetComparands', // #8793 — dispatched for `view`, clean here
'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand DownExpand Up@@ -578,6 +584,7 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)',
// "clean" and "nothing ran" must stay distinguishable.
expect(result.rulesRun).toEqual([
'validatePresetComparands', // #8793 — dispatched for `view`, clean here
'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand Down
6 changes: 5 additions & 1 deletion packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,7 +469,11 @@ export function runRuntimeAuthoringRules(args: {
});
if (!snapshots) return empty;

const ctx: AuthoringRuleContext = { sduiManifest: args.sduiManifest };
// [#9313] `runtimeWriteType` tells a registry-of-registries entry (the
// reference-integrity suite) which per-write snapshot it is judging, so it
// can dispatch its MEMBERS as this gate dispatches entries. CLI callers
// never set it; see `AuthoringRuleContext`.
const ctx: AuthoringRuleContext = { sduiManifest: args.sduiManifest, runtimeWriteType: args.type };
const before = new Set(runRules(rules, snapshots.baseline, ctx).map(fingerprint));
const added = runRules(rules, snapshots.candidate, ctx).filter((f) => !before.has(fingerprint(f)));

Expand Down
Loading
Loading