Skip to content

Commit 53ae2b0

Browse files
committed
test(metadata-protocol): the page save door refuses a props-bag CEL slot over the parse bounds; changeset
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b5362b commit 53ae2b0

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: `os build`, `os validate`, `os lint` and the page save door refuse a page component props CEL slot over the platform's parse bounds (#25002)
6+
7+
Clause-②: no (narrowing: a page component props CEL slot over the platform parse bounds is refused at `os build` and the page door; it ships now under the amended 「甲」, #6015 `6303865025`)
8+
9+
A CEL source over `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) does not parse anywhere on the platform. It faults on every evaluation, and the console fails open, so the element it was written to gate draws for everyone. `page-predicate-over-budget` (`validatePagePredicateBounds`) finds such a source in the CEL slots declared inside a page component's `properties` bag: a `page:tabs` item `visibleWhen`, an `object-form` section or inline field `visibleWhen` / `readonlyWhen` / `requiredWhen`, `record:alert` `visible`, and the `visible` / `disabled` of the `action:*` blocks. The finding was a warning. It is now an error, and its authoring-rules entry moves from the advisory tier to the gating tier. This supersedes the warning-only behaviour described for the same rule earlier in this release line; its message no longer says "A future major release refuses it."
10+
11+
**BREAKING — what moves for consumers.**
12+
13+
- FROM: a warning at `os validate`, `os build` and `os lint`, and an entry on the page save door's `advisories` channel while the page saved. TO: an error. The three commands fail, and an active page write answers `422 INVALID_METADATA` with a `page-predicate-over-budget` issue located at the slot and naming the bound it overruns. The page is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`).
14+
- The page door answers `INVALID_METADATA`, its existing author-time refusal envelope, not `VALIDATION_ERROR`.
15+
16+
**Fix (one line).** Shrink the predicate (collapse an `||` chain into one `in ['a', 'b', …]` test), or precompute the heavy part into a formula or roll-up field on the object and test that one field.
17+
18+
**Unchanged.**
19+
20+
- A within-bounds source draws nothing at any door.
21+
- The component node's own `visibleWhen` keeps its existing verdict (`visibility-predicate-over-budget`), and this rule does not report it twice.
22+
- Stored pages are not migrated and are not refused on read. A stored page with such a slot keeps loading and serving until it is next saved, and that save is judged. Saving a page as a draft is still allowed; publishing the draft is judged.
23+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
24+
- No public export or signature moves; `PagePredicateBoundsFinding.severity` is now `'error'`.
25+
- Measured before crossing, through the built `@objectstack/lint`: 0 in-repo producers carry such a slot. Every page module under `examples/` and `packages/` (30 files, 65 page values) draws 0 findings, and so do the `app-crm`, `app-multi-package` and `app-todo` composed stacks. A literal scan of 9,919 tracked source files (13,840 string literals of 120+ characters) finds 0 sources over the bounds, and HotCRM at `c8a7a70` (611 files) finds 0. A planted 78-term control is found by both instruments.
26+
27+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at `os build`, `os validate`, `os lint` and the page save door of a page component props CEL slot whose source overruns the platform's parse bounds: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored page whose slot is refused keeps loading until it is next saved, and the repair is the author's rewrite of the predicate (shrinking it, or precomputing it into a formula or roll-up field), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3471,6 +3471,116 @@ describe('runtime authoring gate on PAGE writes — the component visibleWhen ve
34713471
});
34723472
});
34733473

3474+
// #25002 — a CEL slot INSIDE a page component's `properties` bag that overruns
3475+
// the platform's parse bounds (`page-predicate-over-budget`,
3476+
// `validatePagePredicateBounds`) is refused at the runtime save door, as
3477+
// `os build` refuses it. #24953 shipped it as an advisory (the door admitted the
3478+
// save and listed the finding); the amended 「甲」 (record 6303865025) ships
3479+
// the refusal in 18.x. The card's shape: a `page:tabs` item `visibleWhen` of
3480+
// ~390 AST nodes, over `maxAstNodes` (256). Pinned through the REAL
3481+
// `saveMetaItem`:
3482+
//
3483+
// (a) the active save is refused — 422 `INVALID_METADATA`, the door's own
3484+
// envelope — naming the slot and the bound, and nothing lands; the draft
3485+
// promotion is refused the same way;
3486+
// (b) the door's finding IS the build's;
3487+
// (c) control: a within-bounds tab predicate saves, and the row lands.
3488+
//
3489+
// ⚠️ As above: this package reaches `@objectstack/lint` through its built
3490+
// `dist/`, so a registry or rule edit is invisible here until
3491+
// `pnpm --filter @objectstack/lint build` has run.
3492+
describe('runtime authoring gate on PAGE writes — a props-bag CEL slot over the parse bounds (#25002)', () => {
3493+
const RULE = 'page-predicate-over-budget';
3494+
const TAB_PATH = 'pages[0].regions[0].components[0].properties.items[1].visibleWhen';
3495+
/** 78 `||` terms, five AST nodes each — the card's ~390-node class. */
3496+
const OVER = Array.from({ length: 78 }, (_, i) => `record.f${i} == ${i}`).join(' || ');
3497+
const WITHIN = "record.stage == 'won' || page.tab != ''";
3498+
const page = (tabVisibleWhen: string) => ({
3499+
name: 'deal_home',
3500+
label: 'Deal',
3501+
type: 'app',
3502+
regions: [{
3503+
name: 'main',
3504+
components: [{
3505+
type: 'page:tabs',
3506+
properties: {
3507+
items: [
3508+
{ label: 'Overview', value: 'overview', children: [] },
3509+
{ label: 'Finance', value: 'finance', visibleWhen: tabVisibleWhen, children: [] },
3510+
],
3511+
},
3512+
}],
3513+
}],
3514+
});
3515+
3516+
const savePage = (protocol: any, item: unknown, extra: Record<string, unknown> = {}) =>
3517+
protocol.saveMetaItem({ type: 'page', name: 'deal_home', item, ...extra });
3518+
const activePageRows = (rows: Map<string, Row>) =>
3519+
Array.from(rows.values()).filter((r) => r.type === 'page' && r.state === 'active');
3520+
3521+
let warn: ReturnType<typeof vi.spyOn>;
3522+
beforeEach(() => {
3523+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
3524+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
3525+
});
3526+
afterEach(() => {
3527+
warn.mockRestore();
3528+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
3529+
});
3530+
3531+
it('(a) REFUSES an active save of the card\'s tab-item `visibleWhen`, naming the bound; nothing stored', async () => {
3532+
const { protocol, rows } = makeProtocol();
3533+
3534+
const err = await savePage(protocol, page(OVER)).catch((e: any) => e);
3535+
3536+
expect(err, 'the save resolved — the door still admits the over-budget slot').toBeInstanceOf(Error);
3537+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
3538+
expect(err.rulesRun).toContain('validatePagePredicateBounds');
3539+
const issues = (err.issues ?? []).filter((i: any) => i.rule === RULE);
3540+
expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1);
3541+
expect(issues[0]).toMatchObject({ severity: 'error', path: TAB_PATH });
3542+
expect(issues[0].message).toContain('`maxAstNodes` budget (platform limit 256)');
3543+
expect(activePageRows(rows)).toEqual([]);
3544+
});
3545+
3546+
it('(a) gates the draft → active promotion of the same page', async () => {
3547+
const { protocol, rows } = makeProtocol();
3548+
await expect(savePage(protocol, page(OVER), { mode: 'draft' })).resolves.toMatchObject({ success: true });
3549+
3550+
const err = await protocol.publishMetaItem({ type: 'page', name: 'deal_home' }).catch((e: any) => e);
3551+
3552+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
3553+
expect((err.issues ?? []).map((i: any) => i.rule)).toContain(RULE);
3554+
expect(activePageRows(rows)).toEqual([]);
3555+
});
3556+
3557+
it('(b) the door and `os build` give the SAME finding', async () => {
3558+
const { protocol } = makeProtocol();
3559+
const err = await savePage(protocol, page(OVER)).catch((e: any) => e);
3560+
const atDoor = (err?.issues ?? []).filter((i: any) => i.rule === RULE);
3561+
3562+
const stack = { pages: [page(OVER)] };
3563+
const atBuild = runAuthoringRules('build', { normalized: stack, parsed: stack })
3564+
.filter((f) => f.severity === 'error');
3565+
3566+
expect(atBuild.map((f) => f.rule)).toEqual([RULE]);
3567+
expect(atDoor).toHaveLength(1);
3568+
for (const k of ['rule', 'where', 'path', 'message', 'hint'] as const) {
3569+
expect(atDoor[0][k], `door and build disagree on '${k}'`).toBe(atBuild[0]![k]);
3570+
}
3571+
});
3572+
3573+
it('(c) CONTROL — a within-bounds tab predicate saves, and the row lands', async () => {
3574+
const { protocol, rows } = makeProtocol();
3575+
3576+
await expect(savePage(protocol, page(WITHIN))).resolves.toMatchObject({ success: true });
3577+
3578+
expect(activePageRows(rows)).toHaveLength(1);
3579+
const stack = { pages: [page(WITHIN)] };
3580+
expect(runAuthoringRules('build', { normalized: stack, parsed: stack }).filter((f) => f.rule === RULE)).toEqual([]);
3581+
});
3582+
});
3583+
34743584
// A page component's `data` prop naming the retired view-level `api` provider,
34753585
// at the runtime save door. `object-grid`, `object-map`, `object-gantt` and
34763586
// `object-tree` take a view data source (`ViewDataSchema`) as `data`, so the

0 commit comments

Comments
 (0)