From d33fad490ced8a179f97151c1f366ccf9bc6a0a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:18:41 +0000 Subject: [PATCH 1/5] test(lint,metadata-protocol): pin the page door's visibility-predicate verdict (#24491) Red before the registry change: the page door does not run the family yet. Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- ...e.page-visibility-predicate-writes.test.ts | 203 ++++++++++++++++++ .../protocol.runtime-authoring-gate.test.ts | 149 +++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts diff --git a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts new file mode 100644 index 00000000000..c365bfc9b27 --- /dev/null +++ b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24491 — the PAGE write door runs the visibility-predicate family. + * + * ## The state this closes + * + * `validateVisibilityPredicates` and `validatePredicatePathRefs` (the #7220 + * family) declared `runtimeTypes: ['view']`, so `runtimeAuthoringRulesFor('page')` + * left them out. A page component `visibleWhen` calling a function the CEL + * environment does not register (`totallyBogusFn(1)`) was refused by + * `os validate`, `os build` and `os lint`, and saved clean through Studio, + * REST `/meta/page` and MCP. At render the predicate faults, the visibility + * fallback is `true`, and the component renders unconditionally. + * + * ## The crossing + * + * Ruling of record 6161173492 on #23388 (letter B) moved the two page component + * rules to this door on one principle: a page with a finding is fixed, then + * saved. The family follows it, so both entries declare `['view', 'page']`. + * No gate change: `page` already maps to `pages`, and the page snapshot holds + * the item as `pages: [item]`. + * + * ## What the path half does on a page + * + * Nothing, at either door. `validatePredicatePathRefs` walks `stack.views` + * only, because its oracle is a schema-bound form's `schemaId`, which a page + * component does not declare. On a page, a wrong reference is the visibility + * rule's verdict: a bare identifier is refused (`visibility-bare-identifier`), + * and a `data.`-rooted path draws the advisory `visibility-root-mislayered`. + * Both doors agree on it. The pin below says so, so that the inert half reads + * as measured and not as coverage. + * + * The protocol-level half (the same verdict through the real `saveMetaItem` + * and `publishMetaItem`, and a stored page that keeps serving until its next + * save) is the #24491 block of + * `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import { runAuthoringRules, type AuthoringFinding } from './authoring-rules.js'; +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; +import { + VISIBILITY_BARE_IDENTIFIER, + VISIBILITY_PREDICATE_OVER_BUDGET, + VISIBILITY_PREDICATE_SYNTAX, + VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, + VISIBILITY_ROOT_MISLAYERED, +} from './validate-visibility-predicates.js'; +import { + PREDICATE_PATH_UNRESOLVED, + PREDICATE_PATH_UNROOTED, + PREDICATE_RHS_PATH_SHAPED, +} from './validate-predicate-path-refs.js'; + +const FAMILY = ['validateVisibilityPredicates', 'validatePredicatePathRefs']; + +/** Every rule id the two family entries emit; the page door also runs the component rules. */ +const FAMILY_IDS = new Set([ + VISIBILITY_BARE_IDENTIFIER, + VISIBILITY_PREDICATE_OVER_BUDGET, + VISIBILITY_PREDICATE_SYNTAX, + VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, + VISIBILITY_ROOT_MISLAYERED, + PREDICATE_PATH_UNRESOLVED, + PREDICATE_PATH_UNROOTED, + PREDICATE_RHS_PATH_SHAPED, +]); + +/** One `page:header` whose props are declared, so a finding is the predicate's. */ +const page = (visibleWhen: unknown) => ({ + name: 'pipeline_home', + label: 'Pipeline', + type: 'app', + regions: [ + { name: 'main', components: [{ type: 'page:header', properties: { title: 'Pipeline' }, visibleWhen }] }, + ], +}); +const AT = 'pages[0].regions[0].components[0]'; +const WHERE = 'page "pipeline_home"'; + +/** + * One refused body per finding kind, each refused by `os build` at `error`. + * The first is the card's shape; the second is the `{ dialect, source }` + * envelope the platform's own pages author through `P`. + */ +const REFUSED = [ + { body: 'totallyBogusFn(1)', rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`totallyBogusFn`' }, + { body: { dialect: 'cel', source: 'totallyBogusFn(1)' }, rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`totallyBogusFn`' }, + { body: "current_user.canApprove(record, 'approve')", rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`canApprove`' }, + // A wrong reference: no binding root resolves `status`. + { body: "status == 'open'", rule: VISIBILITY_BARE_IDENTIFIER, subject: '`status`' }, + { body: 'record.status ==', rule: VISIBILITY_PREDICATE_SYNTAX, subject: 'not valid CEL' }, +] as const; + +/** + * Bodies the build accepts on a page component, and so must the door: the + * shapes the platform and the examples ship (`has()`-guarded record reads, a + * page variable, the acting user, the `P` envelope) and registered functions. + */ +const ACCEPTED = [ + "has(record.status) && record.status == 'in_review'", + { dialect: 'cel', source: 'has(record.id) && has(record.email_verified) && record.email_verified == false' }, + "page.selectedProjectId != ''", + "current_user.email == 'admin@objectos.ai'", + 'size(record.name) > 0', + "upper(record.name) == 'X'", +] as const; + +const gatePage = (item: unknown) => + runRuntimeAuthoringRules({ type: 'page', item, context: { objects: [] } }); + +const family = (fs: readonly T[]): T[] => fs.filter((f) => FAMILY_IDS.has(f.rule)); + +/** The build's family findings for one page, through the build's own entry. */ +const buildFindings = (item: unknown): AuthoringFinding[] => { + const stack = { pages: [item] }; + return family(runAuthoringRules('build', { normalized: stack, parsed: stack })); +}; + +const dump = (r: unknown) => JSON.stringify(r, null, 2); + +describe('#24491 — the page door gives the build\'s visibility-predicate verdict', () => { + it('dispatches `page` writes to both family rules, after the component rules', () => { + expect(runtimeAuthoringRulesFor('page').map((r) => r.name)).toEqual([ + 'validatePresetComparands', + 'validateComponentProps', + 'validateComponentTypes', + 'validatePrintPageBlocks', + ...FAMILY, + ]); + }); + + for (const refused of REFUSED) { + const { body, rule, subject } = refused; + it(`⭐ LIT — a component \`visibleWhen: ${JSON.stringify(body)}\` is REFUSED at the component`, () => { + const result = gatePage(page(body)); + + expect(result.rulesRun).toEqual(expect.arrayContaining(FAMILY)); + const errs = family(result.errors); + expect(errs, dump(result)).toHaveLength(1); + expect(errs[0]).toMatchObject({ rule, severity: 'error', where: WHERE, path: AT }); + expect(errs[0]!.message).toContain(subject); + }); + } + + for (const body of ACCEPTED) { + it(`⭐ CONTROL — a component \`visibleWhen: ${JSON.stringify(body)}\` publishes clean, and the build agrees`, () => { + const result = gatePage(page(body)); + + expect(result.rulesRun).toEqual(expect.arrayContaining(FAMILY)); + expect(family(result.errors), dump(result)).toEqual([]); + expect(family(result.advisories), dump(result)).toEqual([]); + expect(buildFindings(page(body))).toEqual([]); + }); + } + + it('⭐ PARITY — for each refused page body the door findings ARE the build findings', () => { + for (const { body } of REFUSED) { + const atBuild = buildFindings(page(body)); + const atDoor = family(gatePage(page(body)).errors); + + // Non-vacuous: the build refuses each of them. + expect(atBuild.filter((f) => f.severity === 'error'), JSON.stringify(body)).toHaveLength(1); + // Rule, location, message and hint: the whole finding, compared as one. + expect(atDoor, JSON.stringify(body)).toEqual(atBuild); + } + }); + + it('the path half judges nothing on a page: a `data.` path draws the same advisory at both doors, and no refusal', () => { + const body = page("data.tpye == 'formula'"); + const result = gatePage(body); + + expect(result.rulesRun).toContain('validatePredicatePathRefs'); + expect(family(result.errors), dump(result)).toEqual([]); + expect(family(result.advisories).map((f) => f.rule), dump(result)).toEqual([VISIBILITY_ROOT_MISLAYERED]); + expect(family(result.advisories)).toEqual(buildFindings(body)); + }); + + it('the `view` control: a view write is judged exactly as before', () => { + expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + 'validatePresetComparands', + 'validateReferenceIntegrity', + ...FAMILY, + ]); + const view = (visibleWhen: string, data?: unknown) => ({ + name: 'field_editor', + label: 'Field editor', + form: { + ...(data ? { data } : {}), + sections: [{ label: 'Main', fields: [{ field: 'name', visibleWhen }] }], + }, + }); + const gateView = (item: unknown) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects: [] } }); + + const fn = family(gateView(view('totallyBogusFn(1)')).errors); + expect(fn.map((f) => [f.rule, f.path])).toEqual([ + [VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, 'views[0].form.sections[0].fields[0]'], + ]); + // The path half is live on the view surface it was built for. + const path = family(gateView(view("data.tpye == 'text'", { provider: 'schema', schemaId: 'field' })).errors); + expect(path.map((f) => f.rule)).toContain(PREDICATE_PATH_UNRESOLVED); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 5564368cc81..4991e862ea5 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -3205,6 +3205,155 @@ describe('runtime authoring gate on PAGE writes — the component props and type }); }); +// #24491 — the visibility-predicate family at the runtime save door for a +// `page`, on the principle of the ruling of record above (6161173492, letter +// B): a page with a finding is fixed, then saved. `validateVisibilityPredicates` +// and `validatePredicatePathRefs` declare `runtimeTypes: ['view', 'page']`, so +// a page component `visibleWhen` that `os validate` / `os build` / `os lint` +// refuse is refused at Studio, REST `/meta` and MCP with the build's own +// finding. Stored pages keep loading and serving; only the next save of a page +// with such a predicate is refused. The lint-level pins (every finding kind, +// the path half on a page, the `view` control) are `@objectstack/lint`'s +// `runtime-gate.page-visibility-predicate-writes.test.ts`. +// +// ⚠️ This package reaches `@objectstack/lint` through its built `dist/`, so a +// registry edit is invisible here until `pnpm --filter @objectstack/lint build` +// has run. +describe('runtime authoring gate on PAGE writes — the component visibleWhen verdict (#24491)', () => { + const UNKNOWN_FUNCTION = 'visibility-predicate-unknown-function'; + const FAMILY = ['validateVisibilityPredicates', 'validatePredicatePathRefs']; + /** One `page:header` whose props are declared, so a refusal is the predicate's. */ + const page = (visibleWhen: unknown) => ({ + name: 'pipeline_home', + label: 'Pipeline', + type: 'app', + regions: [{ name: 'main', components: [{ type: 'page:header', properties: { title: 'Pipeline' }, visibleWhen }] }], + }); + /** The card's shape: parses, is rooted nowhere wrong, and calls a function nothing registers. */ + const bogusPage = () => page('totallyBogusFn(1)'); + /** Registered functions, the shapes the examples and the platform's pages ship. */ + const ACCEPTED = [ + "has(record.status) && record.status == 'in_review'", + { dialect: 'cel', source: 'has(record.id) && has(record.email_verified) && record.email_verified == false' }, + 'size(record.name) > 0', + ]; + + const savePage = (protocol: any, item: unknown, extra: Record = {}) => + protocol.saveMetaItem({ type: 'page', name: 'pipeline_home', item, ...extra }); + const activePageRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'page' && r.state === 'active'); + /** The build's error findings for one page, through the build's own entry. */ + const buildErrors = (item: unknown) => { + const stack = { pages: [item] }; + return runAuthoringRules('build', { normalized: stack, parsed: stack }).filter((f) => f.severity === 'error'); + }; + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + it('(a) REFUSES an active save of a component `visibleWhen` calling an unknown function, with the build\'s located finding; nothing stored', async () => { + const { protocol, rows } = makeProtocol(); + + const err = await savePage(protocol, bogusPage()).catch((e: any) => e); + + expect(err, 'the save resolved — the door still accepts the page predicate').toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(err.message).toMatch(/^page\/pipeline_home failed author-time validation: 1 issue — /); + expect(err.rulesRun).toEqual(expect.arrayContaining(FAMILY)); + const issues = err.issues.filter((i: any) => i.rule === UNKNOWN_FUNCTION); + expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'error', + where: 'page "pipeline_home"', + path: 'pages[0].regions[0].components[0]', + }); + expect(issues[0].message).toContain('`totallyBogusFn`'); + // A gate that refuses after persisting is a log line. + expect(activePageRows(rows)).toEqual([]); + }); + + it('(a) gates the draft → active promotion of the same page, so the draft door is no bypass', async () => { + const { protocol } = makeProtocol(); + await expect(savePage(protocol, bogusPage(), { mode: 'draft' })).resolves.toMatchObject({ success: true }); + + const err = await protocol.publishMetaItem({ type: 'page', name: 'pipeline_home' }).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(err.issues.map((i: any) => i.rule)).toContain(UNKNOWN_FUNCTION); + }); + + it('(b) the door and `os build` give the SAME finding for the refused page', async () => { + const { protocol } = makeProtocol(); + const err = await savePage(protocol, bogusPage()).catch((e: any) => e); + const atDoor = (err.issues ?? []).filter((i: any) => i.rule === UNKNOWN_FUNCTION); + + const atBuild = buildErrors(bogusPage()); + + // Non-vacuous on both sides: the build refuses the page with this one finding. + expect(atBuild.map((f) => f.rule)).toEqual([UNKNOWN_FUNCTION]); + expect(atDoor).toHaveLength(1); + // Compared key by key: a reworded or relocated door verdict is a second dialect. + for (const k of ['rule', 'where', 'path', 'message', 'hint'] as const) { + expect(atDoor[0][k], `door and build disagree on '${k}'`).toBe(atBuild[0]![k]); + } + }); + + for (const [i, visibleWhen] of ACCEPTED.entries()) { + it(`(c) a page calling only registered functions saves, and the row lands (#${i}: ${JSON.stringify(visibleWhen)})`, async () => { + const { protocol, rows } = makeProtocol(); + + await expect(savePage(protocol, page(visibleWhen))).resolves.toMatchObject({ success: true }); + + expect(activePageRows(rows)).toHaveLength(1); + // Clean at the build too, not only at this door. + expect(buildErrors(page(visibleWhen))).toEqual([]); + }); + } + + it('(d) a stored page with such a predicate keeps loading and serving; its next save is refused until fixed', async () => { + const { engine, rows } = makeStubEngine(); + const registered: Array<{ type: string; name: unknown }> = []; + engine.registry.registerItem = (type: string, item: { name?: unknown }) => { + registered.push({ type, name: item?.name }); + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + // Stored before the crossing, seeded straight into the store because the + // save door now refuses this body. + const stored = bogusPage(); + rows.set(keyOf({ type: 'page', name: 'pipeline_home', organization_id: null, state: 'active' }), { + id: 'r_seed', type: 'page', name: 'pipeline_home', organization_id: null, state: 'active', + metadata: JSON.stringify(stored), + }); + + // Loads: the boot path runs no authoring rule. + const result = await protocol.loadMetaFromDb(); + expect(result).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + expect(registered).toContainEqual({ type: 'page', name: 'pipeline_home' }); + // Serves: the read door hands the stored predicate back as stored. + const served: any = (await protocol.getMetaItem({ type: 'page', name: 'pipeline_home' })).item; + expect(served?.regions?.[0]?.components?.[0]?.visibleWhen).toEqual(stored.regions[0]!.components[0]!.visibleWhen); + + // The next save of the same body is a write, and is judged. + const err = await savePage(protocol, stored).catch((e: any) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(err.issues.map((i: any) => i.rule)).toEqual([UNKNOWN_FUNCTION]); + // The stored row is untouched by the refusal. + expect(JSON.parse(activePageRows(rows)[0]!.metadata)).toEqual(stored); + + // Fixed, then saved. + const fixed = page(ACCEPTED[0]); + await expect(savePage(protocol, fixed)).resolves.toMatchObject({ success: true }); + expect(JSON.parse(activePageRows(rows)[0]!.metadata)).toEqual(fixed); + }); +}); + // A page component's `data` prop naming the retired view-level `api` provider, // at the runtime save door. `object-grid`, `object-map`, `object-gantt` and // `object-tree` take a view data source (`ViewDataSchema`) as `data`, so the From dd417c18d37d4f74eb30638cf61186c8dbcc0ffd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:20:36 +0000 Subject: [PATCH 2/5] feat(lint)!: the visibility-predicate family runs on the page save door (ruling B on #23388) validateVisibilityPredicates and validatePredicatePathRefs declare runtimeTypes ['view', 'page'], so a page component visibleWhen the build refuses is refused at Studio, REST /meta and MCP with the build's finding. Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- .../docs/deployment/validating-metadata.mdx | 2 +- packages/lint/src/authoring-rules.ts | 40 +++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 9d240f1aa5d..4e3f636bea2 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -484,7 +484,7 @@ orthogonal to both, and no cell here can carry it; it is written out in | View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — | | Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ | | Flow trigger readiness — a flow that looks armed and never launches (#5762) | ✓ | ✓ | ✓ | ✓ᶠ | -| `views[]` conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer, schema path refs (ADR-0089 D3b, #7010) | ✓ | ✓ | ✓ | ✓ᵛ | +| `views[]` and page-component conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer, schema path refs (ADR-0089 D3b, #7010; page components at the save door #24491) | ✓ | ✓ | ✓ | ✓ᵛᵖ | | Advisory: record titles, semantic field pointers (ADR-0085), form-section layout, action placement, seed replay/state safety, capability references, liveness | ✓ | ✓ | ✓ | — | | Package docs — flatness, prefixes, links (ADR-0046) | ✓ | ✓ | ✓ | — | | Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) | ✓ | ✓ | — | — | diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 19b9c7fd469..2572e080ca6 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1579,11 +1579,40 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // any.) The reason was not describing this rule; it was the default a rule got // when nobody measured, which is the #4409/#4463 defect one layer in. // + // ─── …and on `page` writes too (#24491), per ruling B on #23388 ─── + // + // The family runs for `page` as well as `view`, on the principle of the + // ruling of record 6161173492 on #23388 (letter B), which moved the two page + // component rules (`validateComponentProps`, `validateComponentTypes`) to this + // door: a page with a finding is fixed, then saved, so one rule holds at + // every door that accepts a page. Before this, a page component `visibleWhen` + // calling a function nothing registers (`totallyBogusFn(1)`) was refused by + // `os validate` / `os build` / `os lint` and saved clean through Studio, + // REST `/meta/page` and MCP. The door's finding is now the build's: the same + // rule id, location, message and hint. + // + // The crossing needed no gate change. `page` already maps to `pages`, and a + // page snapshot holds the item as `pages: [item]` with no `views`, so the + // view half contributes zero findings on a page write, as the page half does + // on a view write. `validatePredicatePathRefs` walks `stack.views` only (its + // oracle is a schema-bound form's `schemaId`, which a page component does not + // declare), so it judges nothing on a page at either door. It declares `page` + // because the family is one wall on the type axis too, which + // `authoring-rule-wiring.test.ts` pins. + // + // Stored pages: the gate judges writes, never the boot or read path, so a + // stored page carrying such a predicate keeps loading and serving, and its + // next save is refused with the fix. Measured at the crossing: 36 shipped + // pages (the examples, `platform-objects`, the plugins, `qa`), 8 component + // `visibleWhen` sites, 0 findings from either rule. + // // Runtime input tier: the gate hands the rules the body as persisted, without // `normalizeStackInput`. That costs the family nothing: the door parses the // body against the spec first (a 422 on refusal), and since the ADR-0089 // aliases retired at 18 (#23543 F4) an accepted body spells the predicate - // `visibleWhen` only, the one key the family reads. + // `visibleWhen` only, the one key the family reads — on a page component as + // on a view form (the page-side `visibility` retired with the view-side + // `visibleOn`). { name: 'validateVisibilityPredicates', tier: 'gating', @@ -1591,7 +1620,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ALL, source: 'packages/lint/src/validate-visibility-predicates.ts', surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['view'], + runtimeTypes: ['view', 'page'], run: (stack) => validateVisibilityPredicates(stack), }, // #7010 — the same predicate surface, one question further in. The three @@ -1627,9 +1656,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ source: 'packages/lint/src/validate-predicate-path-refs.ts', // The second half of the #7220 family move — see the block above the // `validateVisibilityPredicates` entry. This is the rule whose solo wiring - // was reverted; it is wired now because its siblings are. + // was reverted; it is wired now because its siblings are. It declares + // `page` with its sibling (#24491, ruling B on #23388) and judges nothing + // there: it walks `stack.views` only, so a page write draws no finding from + // it at either door. surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['view'], + runtimeTypes: ['view', 'page'], run: (stack) => validatePredicatePathRefs(stack), }, // #1874 — flow authoring anti-patterns. Advisory by default; a finding marked From e2db697bcbbb0b38646343bf52c3081a71ee15af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:24:05 +0000 Subject: [PATCH 3/5] chore(changeset): the page save door's visibility-predicate narrowing (#24491) Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- ...91-page-save-door-visibility-predicates.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/24491-page-save-door-visibility-predicates.md diff --git a/.changeset/24491-page-save-door-visibility-predicates.md b/.changeset/24491-page-save-door-visibility-predicates.md new file mode 100644 index 00000000000..ec703bd925e --- /dev/null +++ b/.changeset/24491-page-save-door-visibility-predicates.md @@ -0,0 +1,29 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: the page save door refuses a page component `visibleWhen` that `os build` refuses (#24491) + +Clause-②: no (narrowing) + +A page component's `visibleWhen` was judged by `os validate`, `os build` and `os lint`, and not by the metadata save door. A page saved through Studio, REST `/meta` or an MCP/AI author answered `200` and was stored when a component predicate called a function nothing registers (such as `totallyBogusFn(1)`), read a bare field (`status == 'open'`), did not parse, or overran the CEL parse budget. At render such a predicate faults, the visibility fallback is `true`, and the component renders for everyone. + +The visibility-predicate family (`validateVisibilityPredicates`, `validatePredicatePathRefs`) now runs on a `page` write as well as on a `view` write, on the principle of ruling B on #23388: a page with a finding is fixed, then saved. The door's verdict is the build's finding: the same rule id (`visibility-predicate-unknown-function`, `visibility-bare-identifier`, `visibility-predicate-syntax`, `visibility-predicate-over-budget`), location (`pages[N].regions[N].components[N]`, and the slot or nested path the build names), message and hint. `visibility-root-mislayered` (a `data.`-rooted predicate on a page) rides the save response as an advisory, as it is a warning at the build. `validatePredicatePathRefs` judges schema-bound form views only, so it adds no finding on a page. + +**BREAKING — what moves for consumers.** This is an accept-set narrowing on the metadata save door, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open in `next` pre mode). + +- FROM: a page write in publish mode answered `200` for a component `visibleWhen` the build refuses. This covers `PUT /api/v1/meta/page/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/page/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). +- TO: it answers `422 INVALID_METADATA`, with the build's issue located at that component. +- Fix: correct the predicate the issue names, as `os build` already requires, and save again. Call a function `introspectScope` lists, read through a bound root (`record.FIELD`, `current_user.FIELD`, `page.VAR`), and fix the syntax the verdict quotes. Saving the page as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + +**The next save of a stored page whose component `visibleWhen` the build refuses is refused with the fix; stored pages keep serving until then.** The door judges writes only: a stored page row is not re-judged at boot, loads and is served as before. + +**Unchanged.** + +- Stored rows are not migrated, and they are not refused on read. +- A `view` write is judged as before. +- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. +- Measured before crossing: the 36 pages this repository ships (the examples, `platform-objects`, `plugin-approvals`, `cloud-connection`, `mcp` and `qa/downstream-contract`) carry 8 component `visibleWhen` predicates. Neither rule finds anything in them, at the build or at the door, before this change or after it; deployed rows were not measured. +- No public export or signature moves. The two registry entries gain `page` in `runtimeTypes`, and the gate itself is unchanged: `page` already mapped to `pages`. + + From 58b7f78bc6946d2023785c6cec7356698914c412 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:27:35 +0000 Subject: [PATCH 4/5] test(lint): the path-refs registry pin reads the family's page crossing (#24491) Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- packages/lint/src/validate-predicate-path-refs.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 8078eacde9a..1866c338b79 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -494,9 +494,12 @@ describe('registry wiring', () => { // here because its siblings are: the solo wiring this rule's own PR // implemented was reverted, and `authoring-rule-wiring.test.ts` now pins the // family property (all of the surface's ids, or none) rather than trusting a - // prose reason to keep the halves in step. + // prose reason to keep the halves in step. #24491 — the family declares + // `page` too (ruling B on #23388); this rule walks `stack.views` only, so a + // page write draws nothing from it, and the declaration keeps the family on + // one type set. expect([...entry!.surfaces]).toEqual(['cli', 'runtime-publish']); - expect([...entry!.runtimeTypes!]).toEqual(['view']); + expect([...entry!.runtimeTypes!]).toEqual(['view', 'page']); expect(entry!.surfaceReason).toBeUndefined(); }); }); From 6d416dfc398aa693a11f807b9577fb30e55053bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:43:32 +0000 Subject: [PATCH 5/5] docs(validating-metadata): scope the visibility row's schema path refs to views[] (#24491) validatePredicatePathRefs walks stack.views only, so no door judges schema path refs on a page component. Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- content/docs/deployment/validating-metadata.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 4e3f636bea2..e238ec8691e 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -484,7 +484,7 @@ orthogonal to both, and no cell here can carry it; it is written out in | View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — | | Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ | | Flow trigger readiness — a flow that looks armed and never launches (#5762) | ✓ | ✓ | ✓ | ✓ᶠ | -| `views[]` and page-component conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer, schema path refs (ADR-0089 D3b, #7010; page components at the save door #24491) | ✓ | ✓ | ✓ | ✓ᵛᵖ | +| `views[]` and page-component conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer; schema path refs on `views[]` only (ADR-0089 D3b, #7010; page components at the save door #24491) | ✓ | ✓ | ✓ | ✓ᵛᵖ | | Advisory: record titles, semantic field pointers (ADR-0085), form-section layout, action placement, seed replay/state safety, capability references, liveness | ✓ | ✓ | ✓ | — | | Package docs — flatness, prefixes, links (ADR-0046) | ✓ | ✓ | ✓ | — | | Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) | ✓ | ✓ | — | — |