Skip to content

Commit 96fb785

Browse files
committed
feat(lint): move the views[] visibility-predicate family to the runtime publish gate (#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 (#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpSiahE9sb96neoWajbiQz
1 parent 8e17759 commit 96fb785

5 files changed

Lines changed: 348 additions & 35 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): the `views[]` visibility-predicate family now gates runtime `view` publishes (#7220)
6+
7+
The four rules that judge a `views[]` conditional-visibility predicate ran on the
8+
three CLI commands only. A `view` written through the runtime publish door —
9+
Studio's designer, REST `/meta`, MCP — was judged by **none** of them, and that
10+
door is the only one most tenants have and the one AI authors use. A predicate
11+
that cannot parse saved clean and then failed OPEN in the console: the element
12+
renders unconditionally, pixel-identical to one carrying no predicate at all
13+
(#5149).
14+
15+
Both registry entries move to `surfaces: ['cli', 'runtime-publish']` with
16+
`runtimeTypes: ['view']`, in one edit. A publish of a `view` is now refused with
17+
`422 invalid_metadata` when a predicate on it:
18+
19+
- is not valid CEL (`visibility-predicate-syntax``===` instead of `==`);
20+
- is valid CEL but overruns a parse bound (`visibility-predicate-over-budget`);
21+
- names a bare identifier no binding root resolves (`visibility-bare-identifier`
22+
`status` instead of `record.status`);
23+
- names a path the target schema does not declare, or a schema key without its
24+
root (`predicate-path-unresolved` / `predicate-path-unrooted`, schema-bound
25+
forms only).
26+
27+
A mis-layered binding root (`visibility-root-mislayered`) is `warning` on every
28+
surface and does not block: it rides back on the 2xx save response under
29+
`advisories` (#4717), which is what let this move happen at all — running rules
30+
and discarding their verdicts is the shape #4463 exists to close.
31+
32+
**They move together on purpose.** The rule for predicate paths had its solo
33+
wiring implemented and then reverted, because a `view` refused for an
34+
unresolvable path while a predicate that does not parse at all walks through the
35+
same door is less predictable than refusing neither. `authoring-rule-wiring.test.ts`
36+
now pins the family property directly — all of this surface's ids are gated at the
37+
runtime door, or none is — so the halves cannot drift apart again.
38+
39+
Nothing changes for `os validate` / `os build` / `os lint`: the runtime door and
40+
the CLI reach identical verdicts (same id, severity and path) on every input, and
41+
a valid `view` still publishes with an empty `advisories` set.
42+
43+
Writes that carry one of the defects above and used to succeed will now be
44+
refused. The findings name the site and the fix, `OS_ALLOW_UNLINTED_METADATA_WRITES=1`
45+
remains the migration hatch, and drafts are never gated — only a publish is.

packages/lint/src/authoring-rule-wiring.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,55 @@ describe('authoring-rule registry wiring (#4409)', () => {
448448
// A type nobody gated returns nothing rather than everything.
449449
expect(runtimeAuthoringRulesFor('translation')).toEqual([]);
450450
});
451+
452+
// ── #7220: a rule FAMILY crosses this wall together, or not at all ──
453+
//
454+
// The guard above makes each rule answer the surface question. It cannot
455+
// catch the failure #7220 names, because every individual answer is
456+
// well-formed: a family whose members split across the wall passes every
457+
// assertion in this file and still ships a door where one verdict about a
458+
// predicate is enforced and its siblings are not. #7214's implementer built
459+
// exactly that state, measured it, and reverted it.
460+
//
461+
// So the family is pinned by NAME here. This is deliberately a stronger
462+
// ratchet than a `surfaceReason` string: a reason is prose that goes stale
463+
// silently, while this fails the moment someone moves one of the two entries
464+
// and not the other — in either direction.
465+
it('the `views[]` visibility-predicate family sits on ONE side of the wall', () => {
466+
const FAMILY = ['validateVisibilityPredicates', 'validatePredicatePathRefs'];
467+
468+
const entries = FAMILY.map((name) => {
469+
const entry = AUTHORING_RULES.find((r) => r.name === name);
470+
expect(entry, `${name} left AUTHORING_RULES — re-point this pin or retire it`).toBeDefined();
471+
return entry!;
472+
});
473+
474+
const wired = entries.filter((e) => e.surfaces.includes('runtime-publish'));
475+
expect(
476+
wired.length === 0 || wired.length === entries.length,
477+
`the views[] visibility-predicate family is SPLIT across the runtime publish gate: `
478+
+ `${wired.map((e) => e.name).join(', ') || '(none)'} run at the door and `
479+
+ `${entries.filter((e) => !e.surfaces.includes('runtime-publish')).map((e) => e.name).join(', ')} `
480+
+ `do not. All of these rules judge the SAME predicate on the SAME surface, so an author `
481+
+ `whose view is refused for one defect and waved through for a sibling defect cannot `
482+
+ `predict the door. Move them together (#7220's ruling) or not at all.`,
483+
).toBe(true);
484+
485+
// Same discipline on the type axis: two family rules gating different
486+
// metadata types is the same split wearing a different hat.
487+
const typeSets = new Set(entries.map((e) => [...(e.runtimeTypes ?? [])].sort().join(',')));
488+
expect(
489+
typeSets.size,
490+
`the family's members declare different runtimeTypes (${[...typeSets].join(' vs ')}) — `
491+
+ `a view write would reach some of them and not others`,
492+
).toBe(1);
493+
494+
// Non-vacuous, and the record of where #7220 left this: both are wired,
495+
// for `view`. Flipping the family back to CLI-only is a legal edit that
496+
// must go through this line rather than around it.
497+
expect(wired.map((e) => e.name)).toEqual(FAMILY);
498+
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual(FAMILY);
499+
});
451500
});
452501

453502
it('every rule declares a source file that exists', () => {

packages/lint/src/authoring-rules.ts

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -351,32 +351,6 @@ const RUNTIME_HEAVY_SOURCE_PARSE =
351351
* wrong 422 there is the whole product, so P1 does not gate them — the issue's
352352
* own worked example, and every acceptance criterion on it, is a flow.
353353
*/
354-
/**
355-
* The rule judges a `views[]` conditional-visibility predicate — and every OTHER
356-
* rule on that surface (the three ADR-0089 D3b rules in
357-
* `validate-visibility-predicates.ts`) is CLI-only.
358-
*
359-
* This reason is deliberately NOT one of the three above: none of them is true
360-
* here. `validatePredicatePathRefs` needs nothing but the written item — its
361-
* oracle is the static `getMetadataTypeSchema` registry, not the tenant's other
362-
* metadata — so the per-write snapshot IS enough, `stackKeyForType('view')`
363-
* already exists, and wiring it would work today.
364-
*
365-
* It is not wired because a HALF-wired wall is worse than an unwired one. A
366-
* Studio `view` write would then be refused for an unresolvable predicate PATH
367-
* while a predicate that does not parse at all (`visibility-predicate-syntax`)
368-
* and one with no root at all (`visibility-bare-identifier`) walked straight
369-
* through the same door — three sibling verdicts about one predicate, one of
370-
* them enforced, and no author able to predict which. The surface should move to
371-
* `runtime-publish` as a FAMILY, in one measured edit, which is a decision about
372-
* `views` writes rather than a rider on #7010's corpus-counted gate.
373-
*/
374-
const RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY =
375-
'Deliberate, and not a snapshot limitation: this rule needs only the written item, but every other '
376-
+ 'rule on the `views[]` visibility-predicate surface (validate-visibility-predicates.ts) is CLI-only. '
377-
+ 'Gating one of three sibling verdicts about the same predicate at the Studio door is less '
378-
+ 'predictable than gating none; move the family together, as one measured edit.';
379-
380354
const RUNTIME_OBJECT_WRITES_P2 =
381355
'P2 (#4463): judges an object/field declaration. Object writes are the hottest metadata path in ' +
382356
'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[] = [
841815
// what decides whether any given diagnostic gates, exactly as `lintFlowPatterns`
842816
// has worked since #3760. The promotion follows the #5762 precedent: a family
843817
// that gains an `error` finding moves its registry tier in the same edit.
818+
//
819+
// ─── The `views[]` visibility-predicate FAMILY at the runtime door (#7220) ───
820+
//
821+
// This entry and `validatePredicatePathRefs` below moved to `runtime-publish`
822+
// in ONE edit, on the maintainer's 2026-08-10 ruling, sequenced after #4717's
823+
// `advisories` channel landed (PR #7435). Before that move a `view` written
824+
// through Studio / REST `/meta` / MCP — the only door most tenants have, and
825+
// the door AI authors use — was judged by NONE of the family's six rule ids.
826+
//
827+
// They move together on purpose, and the two entries carry one comment because
828+
// they are one wall: #7214's implementer wired its own rule here alone and then
829+
// REVERTED it, because a `view` refused for an unresolvable predicate PATH
830+
// while a predicate that does not parse at all walks through the same door is
831+
// less predictable than refusing neither. A half-wired wall is worse than an
832+
// unwired one, so `authoring-rule-wiring.test.ts` now pins the family property
833+
// directly: every id on this surface is gated at the runtime door, or none is.
834+
//
835+
// The previous `surfaceReason` on THIS entry was `RUNTIME_NEEDS_FULL_SNAPSHOT`,
836+
// and re-measuring it at move time found it false: both rule functions read
837+
// `stack.views` and `stack.pages` and NO other collection — never `objects` —
838+
// so the per-write snapshot the gate builds is not partial for them, it is
839+
// complete. (`pages` is simply absent on a `view` write, so the page half
840+
// contributes zero findings to both differential passes rather than inventing
841+
// any.) The reason was not describing this rule; it was the default a rule got
842+
// when nobody measured, which is the #4409/#4463 defect one layer in.
843+
//
844+
// Runtime input tier: the gate hands the rules the body as persisted, without
845+
// `normalizeStackInput`, so the ADR-0087 D2 alias fold does NOT run at this
846+
// door. That costs the family nothing — `validateVisibilityPredicates` reads
847+
// `visibleWhen ?? visibleOn ?? visibility` itself, canonical-first, precisely
848+
// so a caller handing it a raw authored object still gets a verdict.
844849
{
845850
name: 'validateVisibilityPredicates',
846851
tier: 'gating',
847852
input: 'normalized',
848853
commands: ALL,
849854
source: 'packages/lint/src/validate-visibility-predicates.ts',
850-
surfaces: CLI_ONLY,
851-
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
855+
surfaces: CLI_AND_RUNTIME,
856+
runtimeTypes: ['view'],
852857
run: (stack) => validateVisibilityPredicates(stack),
853858
},
854859
// #7010 — the same predicate surface, one question further in. The three
@@ -871,8 +876,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
871876
input: 'normalized',
872877
commands: ALL,
873878
source: 'packages/lint/src/validate-predicate-path-refs.ts',
874-
surfaces: CLI_ONLY,
875-
surfaceReason: RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY,
879+
// The second half of the #7220 family move — see the block above the
880+
// `validateVisibilityPredicates` entry. This is the rule whose solo wiring
881+
// was reverted; it is wired now because its siblings are.
882+
surfaces: CLI_AND_RUNTIME,
883+
runtimeTypes: ['view'],
876884
run: (stack) => validatePredicatePathRefs(stack),
877885
},
878886
// #1874 — flow authoring anti-patterns. Advisory by default; a finding marked

0 commit comments

Comments
 (0)