From 96fb78555ff620c1b6d4e2f5f3bb60c180f038d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 17:26:24 +0000 Subject: [PATCH 1/2] feat(lint): move the views[] visibility-predicate family to the runtime publish gate (#7220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four rules that judge a `views[]` conditional-visibility predicate ran on the three CLI commands only. A `view` written through the runtime publish door — Studio's designer, REST `/meta`, MCP — was judged by NONE of them, and that door is the only one most tenants have and the one AI authors use. A predicate that cannot parse saved clean and then failed OPEN in the console: the element renders unconditionally, pixel-identical to one carrying no predicate (#5149). Both registry entries move to `surfaces: CLI_AND_RUNTIME` with `runtimeTypes: ['view']`, in ONE edit, on the maintainer's 2026-08-10 ruling and sequenced after #4717's `advisories` channel landed (PR #7435). Six rule ids cross together: `visibility-predicate-syntax`, `visibility-predicate-over-budget`, `visibility-bare-identifier`, `visibility-root-mislayered`, `predicate-path-unresolved` and `predicate-path-unrooted`. They move together on purpose. #7214's implementer wired the path rule alone, measured the result and reverted it: a `view` refused for an unresolvable predicate PATH while a predicate that does not parse at all walks through the same door is less predictable than refusing neither. A half-wired wall is worse than an unwired one, so the family property is now PINNED in `authoring-rule-wiring.test.ts` — every id on this surface is gated at the runtime door, or none is — rather than trusted to a prose `surfaceReason` that goes stale silently. `RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY`, the constant that recorded the CLI-only state as a decision, is deleted with its last user. Three things were measured rather than assumed: - The gate needed no dispatch-side change. `runtimeGatedTypes()` is derived from this registry, `TYPE_TO_STACK_KEY` already mapped `view -> views`, and `runtime-authoring-gate.ts` passes the written type straight through — its two `'flow'` literals belong to the gate-local #6285 rule, not to a type allowlist. Declaring is sufficient; `runtimeGatedTypes()` now returns `['flow', 'view']`. - Neither rule needs a stack-wide snapshot. Both read `stack.views` and `stack.pages` and no other collection — never `objects` — so the previous `RUNTIME_NEEDS_FULL_SNAPSHOT` reason on `validateVisibilityPredicates` was describing nothing about this rule. It was the reason a rule got when nobody measured, which is the #4409/#4463 defect one layer in. - The door and `os build` agree. `runtime-gate.test.ts` drives the real dispatch path over an eight-input corpus and asserts the finding sets are IDENTICAL — id, severity and path — so the move changes WHERE the rules run, never WHAT they say. Set equality, not "both non-empty": a half-wired wall is precisely the state where both sides are non-empty and disagree. `visibility-root-mislayered` is `warning` on every surface, so it does not 422 — it reaches the author through the `advisories` channel on the 2xx save response. That channel is why this move was allowed to happen at all: moving the rules before findings could travel back would have run them and discarded the verdicts, the exact shape #4463 exists to close. Behaviour change on a public door (a `view` publish carrying one of these defects is now refused), so it ships a changeset rather than skip-changeset. Drafts are still never gated and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration hatch. Fixes #7220 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QpSiahE9sb96neoWajbiQz --- ...bility-predicate-family-runtime-publish.md | 45 ++++ .../lint/src/authoring-rule-wiring.test.ts | 49 +++++ packages/lint/src/authoring-rules.ts | 68 +++--- packages/lint/src/runtime-gate.test.ts | 207 ++++++++++++++++++ .../src/validate-predicate-path-refs.test.ts | 14 +- 5 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 .changeset/visibility-predicate-family-runtime-publish.md diff --git a/.changeset/visibility-predicate-family-runtime-publish.md b/.changeset/visibility-predicate-family-runtime-publish.md new file mode 100644 index 0000000000..e522a775fd --- /dev/null +++ b/.changeset/visibility-predicate-family-runtime-publish.md @@ -0,0 +1,45 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): the `views[]` visibility-predicate family now gates runtime `view` publishes (#7220) + +The four rules that judge a `views[]` conditional-visibility predicate ran on the +three CLI commands only. A `view` written through the runtime publish door — +Studio's designer, REST `/meta`, MCP — was judged by **none** of them, and that +door is the only one most tenants have and the one AI authors use. A predicate +that cannot parse saved clean and then failed OPEN in the console: the element +renders unconditionally, pixel-identical to one carrying no predicate at all +(#5149). + +Both registry entries move to `surfaces: ['cli', 'runtime-publish']` with +`runtimeTypes: ['view']`, in one edit. A publish of a `view` is now refused with +`422 invalid_metadata` when a predicate on it: + +- is not valid CEL (`visibility-predicate-syntax` — `===` instead of `==`); +- is valid CEL but overruns a parse bound (`visibility-predicate-over-budget`); +- names a bare identifier no binding root resolves (`visibility-bare-identifier` + — `status` instead of `record.status`); +- names a path the target schema does not declare, or a schema key without its + root (`predicate-path-unresolved` / `predicate-path-unrooted`, schema-bound + forms only). + +A mis-layered binding root (`visibility-root-mislayered`) is `warning` on every +surface and does not block: it rides back on the 2xx save response under +`advisories` (#4717), which is what let this move happen at all — running rules +and discarding their verdicts is the shape #4463 exists to close. + +**They move together on purpose.** The rule for predicate paths had its solo +wiring implemented and then reverted, because a `view` refused for an +unresolvable path while a predicate that does not parse at all walks through the +same door is less predictable than refusing neither. `authoring-rule-wiring.test.ts` +now pins the family property directly — all of this surface's ids are gated at the +runtime door, or none is — so the halves cannot drift apart again. + +Nothing changes for `os validate` / `os build` / `os lint`: the runtime door and +the CLI reach identical verdicts (same id, severity and path) on every input, and +a valid `view` still publishes with an empty `advisories` set. + +Writes that carry one of the defects above and used to succeed will now be +refused. The findings name the site and the fix, `OS_ALLOW_UNLINTED_METADATA_WRITES=1` +remains the migration hatch, and drafts are never gated — only a publish is. diff --git a/packages/lint/src/authoring-rule-wiring.test.ts b/packages/lint/src/authoring-rule-wiring.test.ts index 687894f9b6..ca4f38022a 100644 --- a/packages/lint/src/authoring-rule-wiring.test.ts +++ b/packages/lint/src/authoring-rule-wiring.test.ts @@ -448,6 +448,55 @@ describe('authoring-rule registry wiring (#4409)', () => { // A type nobody gated returns nothing rather than everything. expect(runtimeAuthoringRulesFor('translation')).toEqual([]); }); + + // ── #7220: a rule FAMILY crosses this wall together, or not at all ── + // + // The guard above makes each rule answer the surface question. It cannot + // catch the failure #7220 names, because every individual answer is + // well-formed: a family whose members split across the wall passes every + // assertion in this file and still ships a door where one verdict about a + // predicate is enforced and its siblings are not. #7214's implementer built + // exactly that state, measured it, and reverted it. + // + // So the family is pinned by NAME here. This is deliberately a stronger + // ratchet than a `surfaceReason` string: a reason is prose that goes stale + // silently, while this fails the moment someone moves one of the two entries + // and not the other — in either direction. + it('the `views[]` visibility-predicate family sits on ONE side of the wall', () => { + const FAMILY = ['validateVisibilityPredicates', 'validatePredicatePathRefs']; + + const entries = FAMILY.map((name) => { + const entry = AUTHORING_RULES.find((r) => r.name === name); + expect(entry, `${name} left AUTHORING_RULES — re-point this pin or retire it`).toBeDefined(); + return entry!; + }); + + const wired = entries.filter((e) => e.surfaces.includes('runtime-publish')); + expect( + wired.length === 0 || wired.length === entries.length, + `the views[] visibility-predicate family is SPLIT across the runtime publish gate: ` + + `${wired.map((e) => e.name).join(', ') || '(none)'} run at the door and ` + + `${entries.filter((e) => !e.surfaces.includes('runtime-publish')).map((e) => e.name).join(', ')} ` + + `do not. All of these rules judge the SAME predicate on the SAME surface, so an author ` + + `whose view is refused for one defect and waved through for a sibling defect cannot ` + + `predict the door. Move them together (#7220's ruling) or not at all.`, + ).toBe(true); + + // Same discipline on the type axis: two family rules gating different + // metadata types is the same split wearing a different hat. + const typeSets = new Set(entries.map((e) => [...(e.runtimeTypes ?? [])].sort().join(','))); + expect( + typeSets.size, + `the family's members declare different runtimeTypes (${[...typeSets].join(' vs ')}) — ` + + `a view write would reach some of them and not others`, + ).toBe(1); + + // Non-vacuous, and the record of where #7220 left this: both are wired, + // for `view`. Flipping the family back to CLI-only is a legal edit that + // must go through this line rather than around it. + expect(wired.map((e) => e.name)).toEqual(FAMILY); + expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual(FAMILY); + }); }); it('every rule declares a source file that exists', () => { diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9a0e921e0c..3610efe0b2 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -351,32 +351,6 @@ const RUNTIME_HEAVY_SOURCE_PARSE = * wrong 422 there is the whole product, so P1 does not gate them — the issue's * own worked example, and every acceptance criterion on it, is a flow. */ -/** - * The rule judges a `views[]` conditional-visibility predicate — and every OTHER - * rule on that surface (the three ADR-0089 D3b rules in - * `validate-visibility-predicates.ts`) is CLI-only. - * - * This reason is deliberately NOT one of the three above: none of them is true - * here. `validatePredicatePathRefs` needs nothing but the written item — its - * oracle is the static `getMetadataTypeSchema` registry, not the tenant's other - * metadata — so the per-write snapshot IS enough, `stackKeyForType('view')` - * already exists, and wiring it would work today. - * - * It is not wired because a HALF-wired wall is worse than an unwired one. A - * Studio `view` write would then be refused for an unresolvable predicate PATH - * while a predicate that does not parse at all (`visibility-predicate-syntax`) - * and one with no root at all (`visibility-bare-identifier`) walked straight - * through the same door — three sibling verdicts about one predicate, one of - * them enforced, and no author able to predict which. The surface should move to - * `runtime-publish` as a FAMILY, in one measured edit, which is a decision about - * `views` writes rather than a rider on #7010's corpus-counted gate. - */ -const RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY = - 'Deliberate, and not a snapshot limitation: this rule needs only the written item, but every other ' - + 'rule on the `views[]` visibility-predicate surface (validate-visibility-predicates.ts) is CLI-only. ' - + 'Gating one of three sibling verdicts about the same predicate at the Studio door is less ' - + 'predictable than gating none; move the family together, as one measured edit.'; - const RUNTIME_OBJECT_WRITES_P2 = 'P2 (#4463): judges an object/field declaration. Object writes are the hottest metadata path in ' + 'the product, so P1 gates `flow` first and widens once the gate has real traffic behind it.'; @@ -841,14 +815,45 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // what decides whether any given diagnostic gates, exactly as `lintFlowPatterns` // has worked since #3760. The promotion follows the #5762 precedent: a family // that gains an `error` finding moves its registry tier in the same edit. + // + // ─── The `views[]` visibility-predicate FAMILY at the runtime door (#7220) ─── + // + // This entry and `validatePredicatePathRefs` below moved to `runtime-publish` + // in ONE edit, on the maintainer's 2026-08-10 ruling, sequenced after #4717's + // `advisories` channel landed (PR #7435). Before that move a `view` written + // through Studio / REST `/meta` / MCP — the only door most tenants have, and + // the door AI authors use — was judged by NONE of the family's six rule ids. + // + // They move together on purpose, and the two entries carry one comment because + // they are one wall: #7214's implementer wired its own rule here alone and then + // REVERTED it, because a `view` refused for an unresolvable predicate PATH + // while a predicate that does not parse at all walks through the same door is + // less predictable than refusing neither. A half-wired wall is worse than an + // unwired one, so `authoring-rule-wiring.test.ts` now pins the family property + // directly: every id on this surface is gated at the runtime door, or none is. + // + // The previous `surfaceReason` on THIS entry was `RUNTIME_NEEDS_FULL_SNAPSHOT`, + // and re-measuring it at move time found it false: both rule functions read + // `stack.views` and `stack.pages` and NO other collection — never `objects` — + // so the per-write snapshot the gate builds is not partial for them, it is + // complete. (`pages` is simply absent on a `view` write, so the page half + // contributes zero findings to both differential passes rather than inventing + // 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. + // + // Runtime input tier: the gate hands the rules the body as persisted, without + // `normalizeStackInput`, so the ADR-0087 D2 alias fold does NOT run at this + // door. That costs the family nothing — `validateVisibilityPredicates` reads + // `visibleWhen ?? visibleOn ?? visibility` itself, canonical-first, precisely + // so a caller handing it a raw authored object still gets a verdict. { name: 'validateVisibilityPredicates', tier: 'gating', input: 'normalized', commands: ALL, source: 'packages/lint/src/validate-visibility-predicates.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['view'], run: (stack) => validateVisibilityPredicates(stack), }, // #7010 — the same predicate surface, one question further in. The three @@ -871,8 +876,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'normalized', commands: ALL, source: 'packages/lint/src/validate-predicate-path-refs.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY, + // 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. + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['view'], run: (stack) => validatePredicatePathRefs(stack), }, // #1874 — flow authoring anti-patterns. Advisory by default; a finding marked diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index e066c79124..1e24763bf7 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -14,6 +14,9 @@ import { runtimeGatedTypes, stackKeyForType, } from './runtime-gate.js'; +import type { AuthoringFinding } from './authoring-rules.js'; +import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; +import { validatePredicatePathRefs } from './validate-predicate-path-refs.js'; /** The issue's measured example: an approval flow whose expression approver is broken CEL. */ const brokenApprovalFlow = { @@ -206,3 +209,207 @@ describe('runtime publish gate (#4463)', () => { ).not.toThrow(); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// #7220 — the `views[]` visibility-predicate FAMILY at the runtime door +// ──────────────────────────────────────────────────────────────────────────── +// +// The registry move is a two-word edit per entry, and a two-word edit that +// nobody drives is indistinguishable from a declaration nobody reads (#4449, +// and the reason `runtime-gate.test.ts` exists next to the wiring guard at all). +// These tests drive the REAL dispatch path — `runRuntimeAuthoringRules`, the +// same function `packages/metadata-protocol/src/runtime-authoring-gate.ts` +// calls — with `view` bodies a Studio / REST `/meta` / MCP author could save, +// and assert the door now answers. +// +// The card this closes is specifically about the FAMILY, so the last test here +// is the one that matters most: it pins that no input exists on which one member +// fires and another does not. That asymmetry — a `view` refused for an +// unresolvable predicate PATH while a predicate that does not parse at all walks +// through — is exactly what #7214's implementer built, measured and reverted. + +/** A plain runtime view: `record` / `current_user` are the bound roots. */ +const runtimeView = (visibleWhen: string) => ({ + name: 'account_form', + label: 'Account', + object: 'account', + form: { sections: [{ label: 'Main', fields: [{ field: 'name', visibleWhen }] }] }, +}); + +/** + * A schema-bound metadata FORM published as a view — `data` is the bound root. + * `validatePredicatePathRefs` judges only this shape (`#7010`: the `record.*` + * layer's addressable path set is not closed), so the family's path half needs + * it to be non-vacuous. + */ +const schemaBoundForm = (visibleWhen: string) => ({ + name: 'field_editor', + label: 'Field editor', + form: { + data: { provider: 'schema', schemaId: 'field' }, + sections: [{ label: 'Main', fields: [{ field: 'name', visibleWhen }] }], + }, +}); + +/** A predicate that is flawless CEL and overruns `DEFAULT_LIMITS.maxAstNodes`. */ +const overBudget = Array.from({ length: 120 }, (_, i) => `record.name == 'n${i}'`).join(' || '); + +const gateView = (item: unknown) => + runRuntimeAuthoringRules({ type: 'view', item, context: { objects: [] } }); + +describe('the views[] visibility-predicate family at the runtime publish gate (#7220)', () => { + it('dispatches `view` writes to both family rules, from the shared table', () => { + expect(runtimeGatedTypes()).toContain('view'); + expect(stackKeyForType('view')).toBe('views'); + expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + 'validateVisibilityPredicates', + 'validatePredicatePathRefs', + ]); + }); + + // ── fires: the four defects the card names, at the door they bypassed ── + + it('REFUSES a predicate that does not parse as CEL', () => { + const { errors } = gateView(runtimeView("record.status === 'open'")); + const f = errors.find((e) => e.rule === 'visibility-predicate-syntax'); + expect( + f, + `\`===\` is not CEL. Before #7220 this exact body saved clean through Studio and then failed ` + + `OPEN in the console — the element renders unconditionally (#5149).`, + ).toBeDefined(); + // The 422 envelope's four keys: the caller turns `errors` into + // `err.issues` verbatim, so a finding without them is a refusal an author + // cannot act on. + expect(f!.severity).toBe('error'); + expect(f!.path).toBe('views[0].form.sections[0].fields[0]'); + expect(f!.where.length).toBeGreaterThan(0); + expect(f!.message).toMatch(/not valid CEL/); + expect(f!.hint).toMatch(/==/); + }); + + it('REFUSES a bare identifier no binding root resolves', () => { + const { errors } = gateView(runtimeView("status == 'open'")); + const f = errors.find((e) => e.rule === 'visibility-bare-identifier'); + expect(f, '`status` instead of `record.status` resolves nowhere on any layer').toBeDefined(); + expect(f!.severity).toBe('error'); + expect(f!.path).toBe('views[0].form.sections[0].fields[0]'); + expect(f!.hint).toMatch(/record\.status/); + }); + + it('REFUSES a predicate that overruns the CEL parse budget', () => { + const { errors } = gateView(runtimeView(overBudget)); + const f = errors.find((e) => e.rule === 'visibility-predicate-over-budget'); + expect(f, 'same refusal as a syntax fault, different edit (#7217)').toBeDefined(); + expect(f!.severity).toBe('error'); + }); + + it('REFUSES a predicate path the target schema does not declare', () => { + const { errors } = gateView(schemaBoundForm("data.tpye == 'text'")); + const f = errors.find((e) => e.rule === 'predicate-path-unresolved'); + expect( + f, + 'this is the rule whose SOLO wiring #7214 reverted — it is here because its siblings are', + ).toBeDefined(); + expect(f!.severity).toBe('error'); + expect(f!.path).toBe('views[0].form.sections[0].fields[0].visibleWhen'); + expect(f!.message).toMatch(/data\.tpye/); + }); + + it('REFUSES a schema key written without its binding root', () => { + const { errors } = gateView(schemaBoundForm("type == 'text'")); + expect(errors.map((e) => e.rule)).toContain('predicate-path-unrooted'); + }); + + it('reports a MISLAYERED root through the advisory channel, not a refusal', () => { + // The one family member that is `warning` on every surface, so it must not + // 422 — and must not be silent either. #4717's `advisories` channel (the + // ruling's sequencing precondition) is what makes the second half true: it + // rides back on the 2xx `saveMetaItem` response, so the Studio / MCP author + // this gate exists for can actually read it. That channel landing is why + // this move was allowed to happen at all rather than running rules and + // discarding their verdicts (#4463's own shape, one notch quieter). + const result = gateView(runtimeView("data.status == 'open'")); + expect(result.errors).toEqual([]); + const f = result.advisories.find((a) => a.rule === 'visibility-root-mislayered'); + expect(f, 'a wrong-layer paste must still reach the author').toBeDefined(); + expect(f!.severity).toBe('warning'); + }); + + // ── stays quiet ─────────────────────────────────────────────────────── + + it('a valid view still publishes, with the rules having RUN', () => { + const result = gateView(runtimeView("record.status == 'open'")); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]); + // "clean" and "nothing ran" must stay distinguishable. + expect(result.rulesRun).toEqual([ + 'validateVisibilityPredicates', + 'validatePredicatePathRefs', + ]); + }); + + it('does not blame a `view` write for a pre-existing violation elsewhere', () => { + // The D4 subtraction, on the newly gated type: a stored object carrying a + // broken predicate is not this write's to answer for. + const result = runRuntimeAuthoringRules({ + type: 'view', + item: runtimeView("record.status == 'open'"), + context: { + objects: [ + { name: 'account', fields: { name: { type: 'text', visibleWhen: 'broken ===' } } }, + ], + }, + }); + expect(result.errors).toEqual([]); + }); + + // ── the family property the ruling is about ─────────────────────────── + + it('the runtime door and `os build` reach the SAME verdict on every family input', () => { + // The asymmetry #7220 exists to prevent, pinned as a property rather than a + // count: for every input below, the finding set at the runtime gate is + // IDENTICAL — id, severity and path — to the finding set the two rule + // functions produce on the CLI. So there is no input on which one member of + // the family fires at the door and another does not, and no input on which + // the door and `os build` disagree about the same predicate. + // + // Asserted by set equality, not by "both are non-empty": a half-wired wall + // is precisely the state where both sides are non-empty and disagree. + const corpus = [ + runtimeView("record.status === 'open'"), // syntax + runtimeView("status == 'open'"), // bare identifier + runtimeView("data.status == 'open'"), // mislayered root + runtimeView(overBudget), // over budget + runtimeView("record.status == 'open'"), // clean + schemaBoundForm("data.tpye == 'text'"), // unresolvable path + schemaBoundForm("type == 'text'"), // unrooted schema key + schemaBoundForm("data.type == 'text'"), // resolvable path + ]; + + const fingerprints = (fs: readonly AuthoringFinding[]) => + fs.map((f) => `${f.severity}:${f.rule}@${f.path}`).sort(); + + let sawError = false; + let sawAdvisory = false; + + for (const item of corpus) { + const gate = gateView(item); + sawError ||= gate.errors.length > 0; + sawAdvisory ||= gate.advisories.length > 0; + + const stack = { views: [item] }; + const cli = [...validateVisibilityPredicates(stack), ...validatePredicatePathRefs(stack)]; + + expect( + fingerprints([...gate.errors, ...gate.advisories]), + `the runtime gate and os build disagree about ${JSON.stringify(item)} — one family member ` + + `is enforced at a door the others are not, which is the partial-enforcement surface ` + + `#7220 was filed to prevent`, + ).toEqual(fingerprints(cli)); + } + + // Non-vacuous: the corpus really does exercise both halves of the verdict. + expect(sawError, 'the corpus must contain a refusal').toBe(true); + expect(sawAdvisory, 'the corpus must contain an advisory').toBe(true); + }); +}); diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index d4b8c00ac9..577376b50a 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -267,11 +267,15 @@ describe('registry wiring', () => { expect(entry, '#7010 rule missing from AUTHORING_RULES').toBeDefined(); expect(entry!.tier).toBe('gating'); expect([...entry!.commands].sort()).toEqual(['build', 'lint', 'validate']); - // CLI-only, with a reason that is a DECISION rather than a limitation: see - // `RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY`. The whole `views[]` visibility - // family sits on this side of the wall, and it should move together. - expect([...entry!.surfaces]).toEqual(['cli']); - expect(entry!.surfaceReason).toBeTruthy(); + // #7220 — the family moved to the runtime publish gate TOGETHER, so this + // rule is no longer CLI-only and carries no `surfaceReason`. It is wired + // 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. + expect([...entry!.surfaces]).toEqual(['cli', 'runtime-publish']); + expect([...entry!.runtimeTypes!]).toEqual(['view']); + expect(entry!.surfaceReason).toBeUndefined(); }); }); From c05c68653e7d2f40e660c5cf38aa0330f4fa871d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 17:29:29 +0000 Subject: [PATCH 2/2] docs(deployment): the runtime publish gate now judges `view` writes too (#7220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface table in `validating-metadata.mdx` was made wrong by the rule move in this PR, in the two places a reader would actually rely on: - it stated `flow` is the only metadata type any rule declares at the runtime publish door, and that "an object, view, page or dashboard save is checked by the schema parse and by nothing else". Both are now false for `view`; - the `views[]` visibility-predicate family had no row at all, so the table could not say what that door does or does not judge about a predicate. Adds the row with a `✓ᵛ` marker for `view` writes, corrects the footnote to name both gated types, and records WHY the family crossed together — the property the wiring guard now pins — since a reader who takes the table as a menu of independently-wirable rules would draw exactly the conclusion #7220 was filed to prevent. The rest of the page was re-read and is unchanged: the "held to the CLI's verdicts" paragraph, the 422 envelope description and the #6285 runtime-only exception all remain accurate. `content/docs/releases/v17.mdx` also references `@objectstack/lint` and is release-owned — read, left untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QpSiahE9sb96neoWajbiQz --- .../docs/deployment/validating-metadata.mdx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 076124390a..90c4cccffd 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -391,21 +391,32 @@ one. `sys_metadata` overlay rows are not in any config file, so there is no | 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) | ✓ | ✓ | ✓ | ✓ᵛ | | Advisory: record titles, semantic field pointers (ADR-0085), 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) | ✓ | ✓ | — | — | | Naming, labels, data-model conventions, i18n coverage | — | — | ✓ | — | | Emits `dist/objectstack.json` | — | ✓ | — | — | -**`✓ᶠ` means the rule runs at that door for `flow` writes.** `flow` is the only -metadata type any rule declares there today — #4463 shipped P1 as one type and -four rule families, and widening is a data edit nobody has made yet. So an -object, view, page or dashboard save is checked by the schema parse and by +**`✓ᶠ` means the rule runs at that door for `flow` writes; `✓ᵛ` for `view` +writes.** Those are the two metadata types rules declare there today — #4463 +shipped P1 as one type and four rule families, and #7220 added the second type +by moving the whole `views[]` visibility-predicate family across in one edit. So +an object, page or dashboard save is still checked by the schema parse and by nothing else, and the `—` cells above are `—` for two different reasons: some rules read a stack-wide collection a one-item write does not carry (pages, dashboards, navigation, permission sets), and some parse authored source through `typescript`, which the kernel boot path must never load. +The visibility family crossed **together**, and that is the point rather than an +implementation detail. An earlier attempt wired one of its rules alone, which +would have refused a `view` whose predicate names an unresolvable path while a +predicate that does not parse *at all* saved clean through the same door — +sibling verdicts about one predicate, one enforced, none predictable. A rule +family is a wall, and a half-wired wall is worse than an unwired one, so +`authoring-rule-wiring.test.ts` pins the property directly: every rule on that +surface is gated at this door, or none is. + Both halves of a gate's behaviour are on this door. Gating findings **refuse** the write, as the same `422 invalid_metadata` envelope a schema failure produces — `issues[]` carrying `rule`, `path`, `where`, `message` and `hint`, so