From f2e1822ce12df9dbdb00992a66a6782d57c102e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 12:59:34 +0000 Subject: [PATCH 1/3] feat(lint): the two list-view field rules reach the flattened view overlay at the runtime publish gate (#9313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves the card names, because either alone is a silent no-op: - dispatch: the reference-integrity suite's registry entry gains runtimeTypes view; the suite dispatches its MEMBERS by the gate-passed ctx.runtimeWriteType (default flow), so a view snapshot reaches exactly validateSearchableFields + validateSortableFields — never validateActionNameRefs, whose stack.actions universe no per-write snapshot carries (the measured phantom-refusal channel that makes whole-suite granularity wrong for this door); - walk: both rules gain the SELF rung — a views[] entry that IS a flattened list overlay (viewKind 'list', no nested config), the PUT /api/v1/meta/view shape — judging top-level sort/searchableFields with the same binding order and skips as every other list-view surface. CLI commands pass no runtimeWriteType and keep the full suite; flow snapshots keep every member (default declaration). The ViewItem RECORD shape (config.sort one level down) is recorded scope, not walked. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../lint/src/authoring-rule-wiring.test.ts | 6 +- packages/lint/src/authoring-rules.ts | 34 ++- .../lint/src/reference-integrity-suite.ts | 92 +++++++- packages/lint/src/runtime-gate.test.ts | 7 + packages/lint/src/runtime-gate.ts | 6 +- .../lint/src/runtime-gate.view-writes.test.ts | 219 ++++++++++++++++++ .../src/validate-searchable-fields.test.ts | 71 ++++++ .../lint/src/validate-searchable-fields.ts | 30 ++- .../lint/src/validate-sortable-fields.test.ts | 81 +++++++ packages/lint/src/validate-sortable-fields.ts | 59 ++++- 10 files changed, 585 insertions(+), 20 deletions(-) create mode 100644 packages/lint/src/runtime-gate.view-writes.test.ts diff --git a/packages/lint/src/authoring-rule-wiring.test.ts b/packages/lint/src/authoring-rule-wiring.test.ts index d8ff2ec2bb..80e7567fb0 100644 --- a/packages/lint/src/authoring-rule-wiring.test.ts +++ b/packages/lint/src/authoring-rule-wiring.test.ts @@ -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, ]); }); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 6b879381f7..14c323fe5a 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -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 { @@ -640,9 +653,26 @@ 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: + // `validateActionNameRefs` (error-tier) resolves a view's `rowActions[]` / + // `bulkActions[]` against `stack.actions`, a collection no per-write + // snapshot carries, so crossing it would refuse legitimate view writes for + // every stack-level action they name — RUNTIME_NEEDS_FULL_SNAPSHOT's exact + // sentence, on the hottest write type the gate has. 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 diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 1b8cf0300c..de8dc0fe5c 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -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 @@ -108,25 +121,66 @@ 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 a list view's + * `rowActions[]` / `bulkActions[]` against `stack.actions`, which no + * per-write snapshot carries, so crossing it with the suite would refuse a + * legitimate view write for every stack-level action it names — a false 422 + * on the only door a Studio tenant has. + * + * 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) => 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 }, @@ -233,14 +287,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): ReferenceIntegrityFinding[] { +export function validateReferenceIntegrity( + stack: Record, + 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; diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index 2bc6be680d..ceac55d26d 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -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', ]); @@ -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', ]); @@ -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', ]); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index cfe5c1f47f..9ca5e50c86 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -343,7 +343,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))); diff --git a/packages/lint/src/runtime-gate.view-writes.test.ts b/packages/lint/src/runtime-gate.view-writes.test.ts new file mode 100644 index 0000000000..71e6729807 --- /dev/null +++ b/packages/lint/src/runtime-gate.view-writes.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #9313 — the two list-view FIELD rules at the runtime publish gate, on the +// flattened standalone list overlay. +// +// The card's own trap, restated because every test here exists to spring it: +// widening the reference-integrity suite's `runtimeTypes` to `view` is +// necessary and NOT sufficient. The rules' metadata walk read +// `objects[].listViews.*` and `views[].list` / `views[].listViews.*` — never a +// TOP-LEVEL flattened overlay, which is precisely the shape a standalone list +// view takes through `PUT /api/v1/meta/view` (the only door a Studio tenant or +// an MCP/AI author has) and the shape the gate snapshots as `views: [item]`. +// A dispatch-only widening is therefore a silent no-op that reads as coverage, +// and the refusal tests below are the ones that distinguish the two: they FAIL +// with the widening alone and pass only once the walk's self rung exists. +// +// Like `runtime-gate.test.ts`'s #7220 block, everything drives the REAL +// dispatch path — `runRuntimeAuthoringRules`, the function +// `packages/metadata-protocol/src/runtime-authoring-gate.ts` calls — with +// bodies shaped exactly as `saveMetaItem` stores them (identity stamped by +// `normalizeViewMetadata`, console `sort[].id` decorations intact: the store +// persists the ORIGINAL body, so the gate judges them too). + +import { describe, it, expect } from 'vitest'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js'; +import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js'; +import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js'; + +/** The live object universe the gate resolves against (`RuntimeStackContext.objects`). */ +const objects = [ + { + name: 'crm_case', + label: 'Case', + fields: { + name: { type: 'text', label: 'Name' }, + status: { type: 'select', label: 'Status', options: ['open', 'closed'] }, + // A REAL field with no stored column — existence passes, virtuality fails. + days_open: { type: 'formula', label: 'Days Open' }, + }, + }, +]; + +/** + * A flattened standalone list overlay, as the wire carries it: a raw ListView + * config at the TOP level, `object` + `viewKind` required (#7741), plus the + * console decorations a personalization PUT persists — `sort[].id` is + * objectui's `crypto.randomUUID()` row id (#5074), stored verbatim because + * `saveMetaItem` persists the original body. + */ +const overlay = (patch: Record) => ({ + name: 'crm_case.custom', + object: 'crm_case', + viewKind: 'list', + type: 'grid', + columns: ['name', 'status'], + ...patch, +}); + +const gate = (item: unknown) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects } }); + +describe('a flattened list overlay at the runtime publish gate (#9313)', () => { + // ── the refusals the card exists for — red without the walk's self rung ── + + it('REFUSES a top-level `sort` naming an unknown field', () => { + const { errors } = gate(overlay({ + sort: [{ id: 'a2b4c86e-1111-4111-8111-000000000001', field: 'amout', order: 'desc' }], + })); + const f = errors.find((e) => e.rule === SORT_FIELD_UNKNOWN); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].sort[0]'); + expect(f!.where).toContain('flattened list overlay'); + }); + + it('REFUSES a top-level `sort` naming a formula field — no column to ORDER BY', () => { + const { errors } = gate(overlay({ sort: [{ field: 'days_open', order: 'desc' }] })); + const f = errors.find((e) => e.rule === SORT_FIELD_UNSORTABLE); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].sort[0]'); + }); + + it('REFUSES a top-level `searchableFields` entry that resolves to no field', () => { + const { errors } = gate(overlay({ searchableFields: ['name', 'budget'] })); + const f = errors.find((e) => e.rule === SEARCHABLE_FIELD_UNKNOWN); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].searchableFields[1]'); + }); + + it('honors the overlay\'s own `data.object` binding over its top-level `object`', () => { + // ADR-0047's explicit retarget, the same resolution order every other + // list-view rung reads: `days_open` is real on crm_case, so a finding here + // proves the rung resolved against the RETARGETED object, not the identity + // binding. + const { errors } = gate(overlay({ + object: 'crm_other', + data: { provider: 'object', object: 'crm_case' }, + sort: 'nonexistent_field desc', + })); + expect(errors.map((e) => e.rule)).toContain(SORT_FIELD_UNKNOWN); + }); + + // ── what must keep publishing ── + + it('a console-shaped personalization PUT publishes clean — UUID row ids and all', () => { + const result = gate(overlay({ + sort: [{ id: 'a2b4c86e-1111-4111-8111-000000000002', field: 'status', order: 'asc' }], + searchableFields: ['name'], + isPinned: true, + sortOrder: 3, + })); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + // "clean" and "nothing ran" must stay distinguishable. + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + }); + + it('a system-column sort publishes clean — the platform\'s own most common ordering', () => { + const { errors } = gate(overlay({ sort: [{ field: 'created_at', order: 'desc' }] })); + expect(errors, JSON.stringify(errors)).toEqual([]); + }); + + it('an overlay bound to an object outside the live universe is not judged', () => { + // Skip ① — the same cross-package posture every walked surface takes. + const { errors } = gate(overlay({ object: 'pkg_external_case', sort: 'no_such_field' })); + expect(errors, JSON.stringify(errors)).toEqual([]); + }); + + // ── the granularity wall: exactly two members cross, nothing rides along ── + + it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => { + // THE control for the whole-suite granularity decision. + // `validateActionNameRefs` (error-tier) resolves `rowActions[]` against + // `stack.actions` — a collection the per-write snapshot does not carry — + // so if the suite's widening let it ride onto `view` writes, this + // legitimate body would be refused for every stack-level action it names. + // Its member declaration keeps it on `flow` snapshots; the registry entry + // comment in `authoring-rules.ts` carries the measurement. + const result = gate(overlay({ rowActions: ['close_case'], bulkActions: ['mass_close'] })); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + // The suite RAN — the zero above is a dispatch decision, not a dead gate. + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + }); + + it('pins the member surface: exactly the two list-view field rules declare `view`', () => { + const crossed = REFERENCE_INTEGRITY_RULES + .filter((r) => (r.runtimeTypes ?? ['flow']).includes('view')) + .map((r) => r.name); + expect(crossed).toEqual(['validateSearchableFields', 'validateSortableFields']); + // And every 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([]); + }); + + // ── the shapes this card deliberately does not judge ── + + it('a ViewItem RECORD\'s nested `config.sort` is not judged here — recorded scope, not a rung that fell off', () => { + // `{ name, object, viewKind, config }` is `ViewMetadataSchema`'s member 1; + // its bad sort lives one level down, in `config`, which no walk reads. + // #9313's scope is the flattened overlay (the fence in the claim), and the + // record shape is filed as its own follow-up — this pin is the boundary + // marker that keeps the gap RECORDED instead of rediscovered. + const record = { + name: 'crm_case.pipeline', + object: 'crm_case', + viewKind: 'list', + config: { type: 'grid', columns: ['name'], sort: [{ field: 'amout', order: 'desc' }] }, + }; + const { errors } = gate(record); + expect(errors, JSON.stringify(errors)).toEqual([]); + }); + + // ── the D4 differential, on the newly reachable rules ── + + it('does not blame a `view` write for a stored object\'s own bad list view', () => { + // A context object whose built-in list view already violates the sort rule: + // present in BOTH gate passes, so its finding cancels in the differential + // and only the write's own (clean) declaration is judged. + const dirtyContext = [ + { + ...objects[0], + listViews: { aging: { type: 'grid', sort: [{ field: 'days_open', order: 'desc' }] } }, + }, + ]; + const result = runRuntimeAuthoringRules({ + type: 'view', + item: overlay({ sort: [{ field: 'name', order: 'asc' }] }), + context: { objects: dirtyContext }, + }); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + }); + + // ── the flow door is untouched by the member axis ── + + it('a flow write is still judged by the suite\'s NON-view members', () => { + // `validateFlowNodeWrites` declares nothing and so keeps the default + // `flow` surface: a flow whose update_record node writes an unknown field + // is still refused. If the member axis had narrowed flow snapshots to the + // two view members, this zero-cost control would go green. + const badFlow = { + name: 'close_stale', + label: 'Close stale', + trigger: { type: 'record_change', object: 'crm_case', events: ['create'] }, + nodes: [ + { id: 'start', type: 'start' }, + { + id: 'upd', + type: 'update_record', + config: { object: 'crm_case', fields: { no_such_field: 'x' } }, + }, + ], + }; + const result = runRuntimeAuthoringRules({ type: 'flow', item: badFlow, context: { objects } }); + expect(result.errors.map((e) => e.rule), JSON.stringify(result.errors)).toContain( + 'flow-node-write-unknown-field', + ); + }); +}); diff --git a/packages/lint/src/validate-searchable-fields.test.ts b/packages/lint/src/validate-searchable-fields.test.ts index f8b941d046..8d609f1388 100644 --- a/packages/lint/src/validate-searchable-fields.test.ts +++ b/packages/lint/src/validate-searchable-fields.test.ts @@ -297,6 +297,77 @@ describe('validateSearchableFields — list views that narrow the set', () => { expect(findings).toEqual([]); }); + // ── [#9313] the SELF rung: a flattened standalone list overlay ── + // + // The `PUT /api/v1/meta/view` shape — a raw ListView config at the TOP of + // the `views[]` entry (`object` + `viewKind: 'list'` required, #7741). The + // runtime publish gate snapshots a `view` write as `views: [item]`; without + // this rung the #9313 dispatch widening would be a silent no-op. + + it('flags a stale entry on a flattened list overlay\'s top-level set (#9313)', () => { + const findings = validateSearchableFields({ + objects: [objectWithFields], + views: [ + { + name: 'crm_account.custom', + object: 'crm_account', + viewKind: 'list', + type: 'grid', + columns: ['name'], + searchableFields: ['name', 'email'], + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNKNOWN); + expect(findings[0].path).toBe('views[0].searchableFields[1]'); + expect(findings[0].where).toBe('view "crm_account.custom" (flattened list overlay)'); + }); + + it('judges an overlay\'s set as a NARROWING — the #4830 admissibility applies (#9313)', () => { + // A lookup-typed entry in an overlay's set is echoed as the + // `$searchFields` override and refused by the #4254 ingress gate on every + // toolbar search — the same runtime judgment every list-view surface gets. + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_case', + fields: { name: { type: 'text' }, account_id: { type: 'lookup' } }, + }, + ], + views: [ + { + name: 'crm_case.mine', + object: 'crm_case', + viewKind: 'list', + searchableFields: ['name', 'account_id'], + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(findings[0].path).toBe('views[0].searchableFields[1]'); + }); + + it('does NOT read a ViewItem record\'s top level as an overlay (#9313)', () => { + // The record shape carries its set in `config` — a different rung, + // deliberately not walked (recorded scope; see the sort twin's module note). + const findings = validateSearchableFields({ + objects: [objectWithFields], + views: [ + { + name: 'crm_account.pipeline', + object: 'crm_account', + viewKind: 'list', + config: { type: 'grid', columns: ['name'], searchableFields: ['email'] }, + }, + ], + }); + expect(findings).toEqual([]); + }); + it('flags a lookup entry the runtime would refuse — the #4830 defect', () => { // The issue's repro verbatim: `searchableFields: ['name', '']` on a // view, validate all green, first keystroke in the toolbar search → the diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts index c1fe2708ab..0c0533af46 100644 --- a/packages/lint/src/validate-searchable-fields.ts +++ b/packages/lint/src/validate-searchable-fields.ts @@ -530,8 +530,10 @@ export function checkSearchableFieldList( /** * Validate every `searchableFields` declaration in the stack — the object's own - * (the canonical set, ADR-0061) and the list views that narrow it. Returns - * findings (empty = clean). + * (the canonical set, ADR-0061) and the list views that narrow it, including a + * flattened standalone list overlay authored as a `views[]` entry itself + * (#9313 — the `PUT /api/v1/meta/view` shape the runtime publish gate + * snapshots). Returns findings (empty = clean). * * The react page surface (``) is deliberately * NOT walked here: its declaration lives inside JSX source, and @@ -611,6 +613,30 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ // none — the same resolution order `validate-list-view-mode` reads. const viewObject = strName(view.objectName) ?? strName(view.object); + // ── [#9313] The SELF rung: a flattened standalone list overlay ── + // + // A `views[]` entry that IS a single list view — `ViewMetadataSchema`'s + // list-overlay member, the shape a standalone list view takes through + // `PUT /api/v1/meta/view` and the shape the runtime publish gate + // snapshots as `views: [item]`. Recognised exactly as the sort axis + // recognises it (`validate-sortable-fields.ts` carries the full note): + // `viewKind: 'list'` (required on the overlay arm since #7741, refused by + // name on the strict container schema) with no nested `config` (that + // shape is a ViewItem RECORD — its `config.searchableFields` is a + // different rung, deliberately not walked here). A `narrowing`, like + // every list-view surface: the overlay's set is echoed verbatim as the + // `$searchFields` override and judged by the #4254 ingress gate. + if (view.viewKind === 'list' && !isRec(view.config)) { + check( + view.searchableFields, + listViewObject(view) ?? viewObject, + `view "${viewLabel}" (flattened list overlay)`, + `views[${vi}].searchableFields`, + 'list-view searchableFields', + 'narrowing', + ); + } + if (isRec(view.list)) { check( view.list.searchableFields, diff --git a/packages/lint/src/validate-sortable-fields.test.ts b/packages/lint/src/validate-sortable-fields.test.ts index 005d408514..d50b208000 100644 --- a/packages/lint/src/validate-sortable-fields.test.ts +++ b/packages/lint/src/validate-sortable-fields.test.ts @@ -263,6 +263,87 @@ describe('validateSortableFields — the surfaces it walks', () => { it('returns nothing for an empty stack', () => { expect(validateSortableFields({})).toEqual([]); }); + + // ── [#9313] the SELF rung: a flattened standalone list overlay ── + // + // The `PUT /api/v1/meta/view` shape (`ViewMetadataSchema`'s list-overlay + // member): a raw ListView config at the TOP of the `views[]` entry, with + // `object` + `viewKind: 'list'` required (#7741). The runtime publish gate + // snapshots a `view` write as `views: [item]`, so this rung is the half of + // #9313 that makes the dispatch widening mean anything. + + it('walks a flattened list overlay\'s top-level `sort` (#9313)', () => { + const findings = validateSortableFields({ + objects: [{ name: 'crm_lead', fields }], + views: [ + { + name: 'crm_lead.hot', + object: 'crm_lead', + viewKind: 'list', + type: 'grid', + columns: ['name'], + // The console-decorated row shape a personalization PUT persists + // (#5074): `id` is objectui's randomUUID and must not confuse the read. + sort: [{ id: 'a2b4c86e-9313-4111-8111-000000000001', field: 'score', order: 'desc' }], + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNSORTABLE); + expect(findings[0].where).toBe('view "crm_lead.hot" (flattened list overlay)'); + expect(findings[0].path).toBe('views[0].sort[0]'); + }); + + it('honors the overlay\'s `data.object` retarget over its top-level `object` (#9313)', () => { + const findings = validateSortableFields({ + objects: [ + { name: 'crm_lead', fields: { name: { type: 'text' } } }, + { name: 'crm_task', fields: { age: { type: 'formula' } } }, + ], + views: [ + { + name: 'crm_lead.tasks', + object: 'crm_lead', + viewKind: 'list', + data: { provider: 'object', object: 'crm_task' }, + sort: [{ field: 'age', order: 'desc' }], + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('crm_task'); + }); + + it('does NOT read a ViewItem record\'s top level as an overlay (#9313)', () => { + // `{ viewKind, config }` is the RECORD shape — its sort lives in `config`, + // a rung this rule deliberately does not walk (recorded scope, see the + // module note). The structural guard is `config` being a record, mirroring + // the wire schema's own member discrimination. + const findings = validateSortableFields({ + objects: [{ name: 'crm_lead', fields }], + views: [ + { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'grid', columns: ['name'], sort: [{ field: 'score', order: 'desc' }] }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('a form overlay has no sort surface and is not judged (#9313)', () => { + // `FormViewSchema` tombstones `sort` — a related list sorts by its own + // list view's sort — so the rung keys on `viewKind: 'list'` alone. + const findings = validateSortableFields({ + objects: [{ name: 'crm_lead', fields }], + views: [ + { name: 'crm_lead.edit', object: 'crm_lead', viewKind: 'form', sort: 'score desc' }, + ], + }); + expect(findings).toEqual([]); + }); }); describe('checkSortDeclaration — the shared core', () => { diff --git a/packages/lint/src/validate-sortable-fields.ts b/packages/lint/src/validate-sortable-fields.ts index dcefa3fb26..3ea58a4604 100644 --- a/packages/lint/src/validate-sortable-fields.ts +++ b/packages/lint/src/validate-sortable-fields.ts @@ -81,7 +81,16 @@ * * - `objects[].listViews..sort` — built-in named list views; * - `views[].list.sort` — a `defineView` aggregate's default list; - * - `views[].listViews..sort` — its named list views. + * - `views[].listViews..sort` — its named list views; + * - `views[].sort` on a FLATTENED LIST OVERLAY (#9313) — a `views[]` entry + * that IS a single list view (`viewKind: 'list'`, no nested `config`, + * no container slots): `ViewMetadataSchema`'s list-overlay member, the + * shape a standalone list view takes through `PUT /api/v1/meta/view` — + * the only door a Studio tenant or an MCP/AI author has. The runtime + * publish gate builds its per-write snapshot as `views: [item]`, so + * without this rung the dispatch widening (#9313's other half) would be + * a silent no-op that reads as coverage. See the self rung note at the + * walk below for how the shape is recognised. * * NOT walked, each verified against the schema rather than assumed: * @@ -110,16 +119,17 @@ * `ReportSchema.order[].by`. Judging those needs the dataset's measure * index, which is `validateChartBindings`' family, not this one. * - * Two more list-shaped surfaces carry a `sort` and are deliberately left to - * their owners, exactly as the search axis leaves the react page surface to + * One more list-shaped surface carries a `sort` and is deliberately left to + * its owner, exactly as the search axis leaves the react page surface to * `validate-react-page-props`: page/component `sort` - * (`ui/page.zod.ts`, `ui/component.zod.ts` — `walkPageComponents`' territory) - * and the flattened standalone list overlay the metadata door accepts - * (`ViewMetadataSchema`'s list-overlay member, top-level `sort`). The overlay - * reaches the runtime publish gate rather than a stack walk, and the - * reference-integrity suite's runtime dispatch is `runtimeTypes: ['flow']` - * today — widening it is #4463 P2's decision, not this rule's, and the SEARCH - * axis has the identical gap. + * (`ui/page.zod.ts`, `ui/component.zod.ts` — `walkPageComponents`' territory). + * The flattened standalone list overlay used to be listed here too — the + * #4463 P2 gap this module's first landing recorded — and is walked since + * #9313 (the self rung above). Its sibling shape, a standalone ViewItem + * RECORD (`{ name, object, viewKind, config }` — `config.sort` one level + * down), is NOT walked here: #9313's scope is the flattened overlay, and the + * record shape is recorded as its own follow-up rather than silently ridden + * along. * * ── Skips, matching the search axis one for one (ADR-0072 D1) ──────────── * @@ -430,6 +440,35 @@ export function validateSortableFields(stack: AnyRec): SortableFieldFinding[] { // none — the same resolution order `validateSearchableFields` reads. const viewObject = strName(view.objectName) ?? strName(view.object); + // ── [#9313] The SELF rung: a flattened standalone list overlay ── + // + // A `views[]` entry that IS a single list view — `ViewMetadataSchema`'s + // list-overlay member (`ListViewSchema.extend(flattenedViewOverlayFields())`), + // the shape a standalone list view takes through `PUT /api/v1/meta/view` + // and the shape the runtime publish gate snapshots as `views: [item]`. + // Recognised by the wire schema's own structural discrimination: + // `viewKind: 'list'` (REQUIRED on the overlay arm since #7741, and a key + // the strict container schema refuses by name, so a container can never + // match) with no nested `config` (a body carrying one is a ViewItem + // RECORD, whose `config.sort` is a different rung — deliberately not + // walked here, see the module note). `view-walk.ts` established the same + // `self` rung for the section-carrying shapes; this is its list twin. + // + // The binding order matches every other list-view rung: the overlay's own + // `data.object` (ADR-0047 explicit retarget) ahead of its required + // top-level `object`. A console personalization PUT arrives with + // `sort: [{ id, field, order }]` rows carrying objectui's row `id` + // (#5074) — `readSortKeys` reads `field` and ignores the decoration. + if (view.viewKind === 'list' && !isRec(view.config)) { + check( + view.sort, + listViewObject(view) ?? viewObject, + `view "${viewLabel}" (flattened list overlay)`, + `views[${vi}].sort`, + 'list-view sort', + ); + } + if (isRec(view.list)) { check( view.list.sort, From 6c054b17d9da137b2b72a595261d6070b7dc9ffd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:32:53 +0000 Subject: [PATCH 2/3] chore: re-create rescued changeset from issue record (#9313) The changeset was untracked when the original dev died on the fable quota; its text was preserved verbatim in issue comment 5342539755. Re-created from that block, not re-derived. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- .changeset/view-door-list-view-field-rules.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/view-door-list-view-field-rules.md diff --git a/.changeset/view-door-list-view-field-rules.md b/.changeset/view-door-list-view-field-rules.md new file mode 100644 index 0000000000..0d99da7bf1 --- /dev/null +++ b/.changeset/view-door-list-view-field-rules.md @@ -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 102 shipped +view-door bodies (10 containers + 92 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. From 2c119f2ccde372125c795e345c00b66481164ee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 14:02:24 +0000 Subject: [PATCH 3/3] =?UTF-8?q?test(lint):=20make=20the=20granularity=20co?= =?UTF-8?q?ntrol=20discriminate=20=E2=80=94=20container-shaped=20phantom-c?= =?UTF-8?q?hannel=20pin=20(#9313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured by ablation: crossing validateActionNameRefs onto view left the flattened-overlay rides-along control GREEN, because that rule walks views[].list / views[].listViews.* and has no flattened-overlay rung — on that shape the crossing is a silent no-op, not a phantom refusal. The channel is real on CONTAINER view writes (measured: action-name-undefined on the per-write snapshot, clean on the full stack), so the behavioural control now uses a container body naming stack-level actions, and the source comments state the two measured failure shapes instead of one. Changeset FP numbers re-derived on this tree: 0 refusals / 0 advisories over 50 shipped view-door bodies (11 containers + 39 console-shaped personalization overlays) across four authoring lineages — the rescued text claimed 102 (10+92); the measured population replaces it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- .changeset/view-door-list-view-field-rules.md | 4 +- packages/lint/src/authoring-rules.ts | 19 ++++++---- .../lint/src/reference-integrity-suite.ts | 10 +++-- .../lint/src/runtime-gate.view-writes.test.ts | 37 +++++++++++++++---- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/.changeset/view-door-list-view-field-rules.md b/.changeset/view-door-list-view-field-rules.md index 0d99da7bf1..76be7c7877 100644 --- a/.changeset/view-door-list-view-field-rules.md +++ b/.changeset/view-door-list-view-field-rules.md @@ -27,8 +27,8 @@ the two list-view field rules (`ReferenceIntegrityRule.runtimeTypes`, default 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 102 shipped -view-door bodies (10 containers + 92 console-shaped personalization overlays, +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 diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 14c323fe5a..c96f48988d 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -663,13 +663,18 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // 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: - // `validateActionNameRefs` (error-tier) resolves a view's `rowActions[]` / - // `bulkActions[]` against `stack.actions`, a collection no per-write - // snapshot carries, so crossing it would refuse legitimate view writes for - // every stack-level action they name — RUNTIME_NEEDS_FULL_SNAPSHOT's exact - // sentence, on the hottest write type the gate has. Flow snapshots are - // unchanged: every member keeps the default `flow` declaration. + // 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', 'view'], run: (stack, ctx) => validateReferenceIntegrity(stack, ctx), diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index de8dc0fe5c..d593eea187 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -132,11 +132,13 @@ export interface ReferenceIntegrityRule { * 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 a list view's - * `rowActions[]` / `bulkActions[]` against `stack.actions`, which no + * `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 view write for every stack-level action it names — a false 422 - * on the only door a Studio tenant has. + * 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": diff --git a/packages/lint/src/runtime-gate.view-writes.test.ts b/packages/lint/src/runtime-gate.view-writes.test.ts index 71e6729807..14826ccfcf 100644 --- a/packages/lint/src/runtime-gate.view-writes.test.ts +++ b/packages/lint/src/runtime-gate.view-writes.test.ts @@ -127,19 +127,42 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { // ── the granularity wall: exactly two members cross, nothing rides along ── it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => { - // THE control for the whole-suite granularity decision. - // `validateActionNameRefs` (error-tier) resolves `rowActions[]` against - // `stack.actions` — a collection the per-write snapshot does not carry — - // so if the suite's widening let it ride onto `view` writes, this - // legitimate body would be refused for every stack-level action it names. - // Its member declaration keeps it on `flow` snapshots; the registry entry - // comment in `authoring-rules.ts` carries the measurement. + // A regression pin on the FLATTENED shape, not the granularity + // discriminator: `validateActionNameRefs` walks `views[].list` / + // `views[].listViews.*` and has no flattened-overlay rung, so on THIS body + // it stays silent even if its member declaration crossed onto `view` + // (measured by ablation — crossing it left this test green). The + // behavioural discriminator is the CONTAINER control below; the + // declaration itself is pinned by the member-surface test. const result = gate(overlay({ rowActions: ['close_case'], bulkActions: ['mass_close'] })); expect(result.errors, JSON.stringify(result.errors)).toEqual([]); // The suite RAN — the zero above is a dispatch decision, not a dead gate. expect(result.rulesRun).toContain('validateReferenceIntegrity'); }); + it('does NOT refuse a CONTAINER view write naming a stack-level action — the phantom channel stays walled', () => { + // THE behavioural control for the granularity decision, on the shape + // where the channel is real. A container's `list.rowActions` IS walked by + // `validateActionNameRefs` (`views[].list` rung), and the per-write + // snapshot carries no `stack.actions` — so if the suite's widening let + // that member ride onto `view` writes, this legitimate body would be + // refused with `action-name-undefined` for every stack-level action it + // names (measured: 1 finding on the snapshot shape, 0 with the full + // stack). The member wall is what keeps this green. + const container = { + list: { + type: 'grid', + data: { provider: 'object', object: 'crm_case' }, + columns: ['name', 'status'], + rowActions: ['stack_level_close_case'], + bulkActions: ['stack_level_mass_close'], + }, + }; + const result = gate(container); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + }); + it('pins the member surface: exactly the two list-view field rules declare `view`', () => { const crossed = REFERENCE_INTEGRITY_RULES .filter((r) => (r.runtimeTypes ?? ['flow']).includes('view'))