Skip to content

test(platform): gate canonical ExpressionInputSchema envelopes on platform Page exports - #11481

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-11255-page-envelope-gate
Aug 23, 2026
Merged

test(platform): gate canonical ExpressionInputSchema envelopes on platform Page exports#11481
os-steve merged 2 commits into
mainfrom
claude/issue-11255-page-envelope-gate

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#11255

The silent un-gating this closes

A Page authored as a raw typed object literal (export const X: Page = { … }) is type-checked but never parsed, so ExpressionInputSchema's bare-string transform never runs and whatever the author wrote reaches /api/v1/meta/page verbatim; objectui routes bare strings to its legacy JS evaluator for its back-compat window, and that evaluator has no has(). Component visibility is fail-soft, so a guarded predicate throws and resolves to SHOWN — a declared gate stops gating, and in a production console bundle nothing is logged at all, because SchemaRenderer's diagnostic probe sits behind if (__DEV__).

Every authoring-time signal stays green while that is true: the type accepts the bare string, tsc passes, the suite passes, the gate farm passes. That is why this is a gate and not a review habit. The one live instance was fixed in #11254; this makes the next one impossible to ship silently.

Covered surface, and why

packagepagescoveredwhy
packages/platform-objectsSysUserDetailPage, SysOrganizationDetailPage, SysPositionDetailPageyesmandatory — shipped platform page metadata, all three raw literals
packages/qa/downstream-contractWelcomePagenoit does not ship the same way: private: true, a frozen third-party-consumer fixture whose file header states its purpose is to author "exactly like this", i.e. the way an external project on a published release naively would. It reaches no wire, and a gate demanding canonical envelopes there would contradict the contract the fixture exists to express.
packages/cloud-connectionCloudConnectionSettingsPageno — filed as #11480a fourth raw-literal page the card's *.page.ts sweep missed, because it lives in cloud-connection-ui.ts. It is published and does reach the kernel via the plugin's manifest bundle, so it is the same class — but it carries zero expression keys today, and covering it needs the detector in a shared home (@objectstack/lint public API) rather than a second copy. That is an API call, not a test call.

The gate does not trust the filename. It scans this package's own src/ for export const X: Page = declarations and asserts the pages barrel covers every one, so a page added tomorrow in a *-ui.ts file is caught here rather than missed the way the original sweep missed one.

Detector design

There is no single parse that reaches every expression position on a page, so the gate uses three doors and unions their findings:

doorparsesreachesblind to
1 PageSchemathe whole pageevery schema-typed positionanything inside properties
2 PageComponentSchemaeach walked componentcomponents nested INSIDE properties (page:tabsitems[].children[], page:cardbody/footer)the properties bag itself
3 ComponentPropsMap[type]each component's propertiesexpression keys the per-type props schema declares (record:alert.properties.visible)types absent from the map

Doors 2 and 3 exist because PageComponentSchema.properties is z.record(z.unknown()) — an opaque bag served verbatim. Door 1 walks straight past a whole nested component tree, and a props-level predicate never reaches ExpressionInputSchema at all.

Positions are identified behaviourally, not by name. Nothing hardcodes visibleWhen. Each door walks the authored object and its parsed counterpart in lockstep and flags exactly one shape: the author wrote a string, and the parse turned that same string into an expression envelope. That is the observable signature of an ExpressionInputSchema position and of nothing else — so visibility, every dialect (cel / cron / template), and any expression key added later are covered with no edit here. Iteration is driven by the authored side, so keys the parse merely materializes (defaults) are never mistaken for findings. The deprecated-alias case is caught too: when the parse consumed an authored key and re-homed its value, the finding names both.

Component traversal reuses walkPageComponents from @objectstack/lint rather than reimplementing it — that walk's own header records that duplicating it already produced one dead rule.

Preconditions are asserted, not assumed. A door that cannot parse reports nothing, which is indistinguishable from clean. So each door's precondition is its own test (the page parses, every component parses, every type is mapped, every authored props bag parses). If one goes red, the message says which door stopped reading instead of this file going quietly green over a population it no longer covers.

Negative-control proof

Six controls, and each one shows a door catching something the others cannot — none is decoration:

  • bare predicate on a top-level component → caught, and the rendered failure names page + key path.
  • the same predicate as a canonical envelope → zero findings (not blanket flagging), with the preconditions asserted green on the control fixture so the pass is a real verdict.
  • bare predicate on a component nested inside properties → caught by door 2, and door 1 run in isolation is asserted to find nothing — proof door 2 is load-bearing.
  • bare predicate inside the properties bag → caught by door 3, with doors 1+2 run in isolation asserted to find nothing.
  • bare predicate at the deprecated visibility key → caught, reporting the canonical key it normalizes to.
  • the shippedSysUserDetailPage, deep-cloned with its one envelope down-graded to the bare source it wraps → caught, naming sys_user_detail · slots.alerts[0].visibleWhen; the untouched export is asserted still clean in the same test.

Beyond the in-memory controls, the operative acceptance criterion was exercised as a real ablation against the shipped source: sys-user.page.ts's predicate was rewritten from the P envelope to the bare string on disk (mutation confirmed on disk before the run — envelope-form occurrences 1 to 0, bare-form 0 to 1), the gate ran red at exit 1, and a restore trap put the file back (restore confirmed: envelope-form back to 1, bare-form 0, git status --porcelain empty). The red reads:

SysUserDetailPage (sys_user_detail) · slots.alerts[0].visibleWhen
authored BARE: "has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false"
seen by: PageSchema
fix: author the canonical envelope — P`…` (or { dialect: 'cel', source: … }).
This page is a raw object literal: nothing parses it, so a bare string
reaches the wire verbatim and the console routes it to its LEGACY
evaluator, where a fail-soft predicate stops gating silently.

Verification

All runs below are on 1060c357c3, serialized through scripts/pm/os-verify-lock.sh; exit codes captured before any pipe.

  • pnpm --filter @objectstack/platform-objects typecheck — exit 0.
  • pnpm --filter @objectstack/platform-objects test — exit 0, 28 files / 454 tests passed (this file contributes 22).
  • node scripts/check-cross-package-test-inputs.mjs --self-test && node scripts/check-cross-package-test-inputs.mjs — exit 0 each; "OK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob."
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (never a hand-rolled diff), all exit 0: check:published-files, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-ci-filter-parity, check-plugin-teardown-shape, check-affected-docs, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher, check:i18n, check:nul-bytes, check:type-check-debt.

Two of those found real defects in this diff and are worth naming, since both are exactly the class this PR is about:

  • check:nul-bytes — a raw NUL byte materialized into the new file while it was being written. Fixed; the gate now reports "no raw ASCII control bytes".
  • check:type-check-debt --re-measure — red with @objectstack/platform-objects: TEST_DEBT records 3 … now reports 4 (+1). This package's tsconfig.json excludes **/*.test.ts, so pnpm typecheck passing said nothing about the new test file; the ratchet is what reads that layer. The error was TS1470: 'import.meta' … not allowed in files which will build into CommonJS output — the seed for the source scan. Repaired the way this package's sibling managed-api-method-affordance-sweep.test.ts already documents (__dirname, which also stays a spelling check:cross-package-test-inputs resolves statically), not by touching the ledger. Re-measured: back to the recorded 3, all three pre-existing and in feature-gate-guard.test.ts.

No changeset: this is a tests-only diff that publishes nothing, matching the two most recent merged tests-only PRs on main (#11416, #11403), neither of which carried one. skip-changeset applied.

Scope: no packages/spec/src/** in the diff — no contract acceptance changed, no public surface widened. Option B (routing platform pages through definePage()) and objectui's bare-string back-compat window are untouched, per the card's triage.

The first gate run surfaced no pre-existing looseness in the platform pages: all three parse clean through PageSchema, all 34 walked components parse clean through PageComponentSchema, and every authored properties bag parses against its declared props schema.


Generated by Claude Code

…tform Page exports
A `Page` authored as a raw typed object literal is never parsed, so
`ExpressionInputSchema`'s bare-string -> `{ dialect: 'cel', source }` transform
never runs and whatever the author wrote reaches the wire verbatim. objectui
routes bare strings to its legacy JS evaluator, which has no `has()`; component
visibility is fail-soft, so a guarded predicate throws and resolves to SHOWN --
a declared gate stops gating, silently, in a production console bundle.
Every authoring-time signal stays green while that is true, so this adds the
gate rather than relying on review. Three parse doors, unioned:
1. `PageSchema` over the whole page -- every schema-typed position.
2. `PageComponentSchema` over each walked component -- the components nested
inside the opaque `properties` bag that door 1 walks straight past.
3. `ComponentPropsMap[type]` over each `properties` bag -- the expression keys
a per-type props schema declares (`record:alert.properties.visible`).
Positions are identified behaviourally, not by name: each door walks authored
vs parsed in lockstep and flags "the author wrote a string, the parse turned
that same string into an envelope". Nothing hardcodes `visibleWhen`, so the
deprecated `visibility` alias, every dialect, and any expression key added later
are covered without an edit here.
Each door's precondition is asserted too -- a door that cannot parse reports
nothing, which is indistinguishable from clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2
`check:type-check-coverage --re-measure` reported
`@objectstack/platform-objects: TEST_DEBT records 3 … now reports 4 (+1)`.
This package's tsconfig.json excludes `**/*.test.ts`, so its own
`pnpm typecheck` never read the new test file at all -- passing green while
saying nothing about it. The ratchet is what reads that layer, and the error it
found was TS1470: `import.meta` is not allowed in files that build into
CommonJS output.
Repaired the way this package's sibling `managed-api-method-affordance-sweep`
already documents: seed from `__dirname`, which type-checks under the package's
own config, is defined at runtime by vitest's transform, and stays one of the
spellings `check:cross-package-test-inputs` resolves statically. The ledger is
untouched -- fixing the error is the author's remedy; raising a shrink-only
ratchet is not.
Also sharpens the mutation control's precondition message: when the shipped page
is ALREADY bare, that assertion is not the failure to read -- the gate above is,
and it names the offending path.
Re-measured: back to the recorded 3, all three pre-existing in
feature-gate-guard.test.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

2 participants

@os-steve@claude