Skip to content

Commit 333769d

Browse files
os-helpclaude
andauthored
feat(lint): move the views[] visibility-predicate family to the runtime publish gate (#7220) (#7479)
* 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 * docs(deployment): the runtime publish gate now judges `view` writes too (#7220) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpSiahE9sb96neoWajbiQz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 36237a1 commit 333769d

6 files changed

Lines changed: 363 additions & 39 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.

content/docs/deployment/validating-metadata.mdx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -391,21 +391,32 @@ one. `sys_metadata` overlay rows are not in any config file, so there is no
391391
| View references — form targets, view-key collisions (#2554) |||||
392392
| Flow authoring anti-patterns (#1874) |||| ✓ᶠ |
393393
| Flow trigger readiness — a flow that looks armed and never launches (#5762) |||| ✓ᶠ |
394+
| `views[]` conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer, schema path refs (ADR-0089 D3b, #7010) |||| ✓ᵛ |
394395
| Advisory: record titles, semantic field pointers (ADR-0085), seed replay/state safety, capability references, liveness |||||
395396
| Package docs — flatness, prefixes, links (ADR-0046) |||||
396397
| Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) |||||
397398
| Naming, labels, data-model conventions, i18n coverage |||||
398399
| Emits `dist/objectstack.json` |||||
399400

400-
**`✓ᶠ` means the rule runs at that door for `flow` writes.** `flow` is the only
401-
metadata type any rule declares there today — #4463 shipped P1 as one type and
402-
four rule families, and widening is a data edit nobody has made yet. So an
403-
object, view, page or dashboard save is checked by the schema parse and by
401+
**`✓ᶠ` means the rule runs at that door for `flow` writes; `✓ᵛ` for `view`
402+
writes.** Those are the two metadata types rules declare there today — #4463
403+
shipped P1 as one type and four rule families, and #7220 added the second type
404+
by moving the whole `views[]` visibility-predicate family across in one edit. So
405+
an object, page or dashboard save is still checked by the schema parse and by
404406
nothing else, and the `` cells above are `` for two different reasons: some
405407
rules read a stack-wide collection a one-item write does not carry (pages,
406408
dashboards, navigation, permission sets), and some parse authored source through
407409
`typescript`, which the kernel boot path must never load.
408410

411+
The visibility family crossed **together**, and that is the point rather than an
412+
implementation detail. An earlier attempt wired one of its rules alone, which
413+
would have refused a `view` whose predicate names an unresolvable path while a
414+
predicate that does not parse *at all* saved clean through the same door —
415+
sibling verdicts about one predicate, one enforced, none predictable. A rule
416+
family is a wall, and a half-wired wall is worse than an unwired one, so
417+
`authoring-rule-wiring.test.ts` pins the property directly: every rule on that
418+
surface is gated at this door, or none is.
419+
409420
Both halves of a gate's behaviour are on this door. Gating findings **refuse**
410421
the write, as the same `422 invalid_metadata` envelope a schema failure
411422
produces — `issues[]` carrying `rule`, `path`, `where`, `message` and `hint`, so

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)