Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/visibility-predicate-family-runtime-publish.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 15 additions & 4 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/lint/src/authoring-rule-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
68 changes: 38 additions & 30 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading