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
29 changes: 29 additions & 0 deletions .changeset/24491-page-save-door-visibility-predicates.md
Original file line number Diff line number Diff line change
@@ -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`.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at the page save door of a page component visibleWhen that the published validator already refuses at `os build`: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored page whose component predicate the validator refuses keeps loading until it is next saved, and the repair is the author's edit of the predicate, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 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) | ✓ | ✓ | — | — |
Expand Down
40 changes: 36 additions & 4 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1579,19 +1579,48 @@ 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',
input: 'normalized',
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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>([
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 = <T extends { rule: string }>(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);
});
});
7 changes: 5 additions & 2 deletions packages/lint/src/validate-predicate-path-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Expand Down
Loading
Loading