Repository navigation
Description
Filed unassigned by the domain:engine seat while implementing #9167 round 2 (session session_01RfyXxZ2WPjcjhuXpiQQc3y, PR #11254). Recording the class; the one live instance is fixed in that PR. Not claimed, not queued.
The shape
PageComponentSchema.visibleWhen is an ExpressionInputSchema (packages/spec/src/ui/page.zod.ts:189), whose transform normalizes a bare string into the canonical { dialect: 'cel', source } envelope. That transform only runs if something parses the page. A page authored as
export const SysUserDetailPage: Page = { … }is a plain typed object literal — the type is checked, nothing is parsed — so whatever the author wrote reaches /api/v1/meta/page verbatim. A page authored through definePage({ … }) is parsed and serves the envelope.
Measured on origin/main @ 824a996c0 with a real dev server, the two authored record:alert gates side by side after the #9167 migration text was applied literally to both:
| page | authoring form | visibleWhen on the wire |
|---|---|---|
showcase_task_detail (definePage) |
parsed | {"dialect":"cel","source":"has(record.status) && …"} |
sys_user_detail (raw Page literal) |
not parsed | "has(record.id) && …" — bare |
Why bare is not merely cosmetic
objectui's ExpressionEvaluator.evaluateCondition at the pinned SHA (190fbd01d) routes by shape, deliberately:
Bare strings and
${…}templates stay on the legacy path (back-compat deprecation window); only an explicit{ dialect: 'cel' }envelope is rerouted.
The legacy JS evaluator has no has(), and the component-visibility surface is fail-soft, so a guarded predicate throws and resolves to SHOWN. Measured in the real console, on the bare form:
| run | probe | verdict | expected |
|---|---|---|---|
| B2 | sys_user self, email_verified forced true |
VISIBLE | hidden ❌ |
| B3 | another user's profile (control: that page's own title) | VISIBLE | hidden ❌ |
Isolated to the envelope alone — one page, one predicate source, one rewrite path:
| run | probe | verdict |
|---|---|---|
| P1 | node visibleWhen rewritten to the bare string |
VISIBLE ❌ |
| P2 | node visibleWhen rewritten to the envelope, same source |
hidden ✅ |
And in a production console bundle the fault is completely silent: SchemaRenderer's evaluateVisibilityPredicate short-circuits on if (!__DEV__) before the diagnostic probe, so nothing is logged. The whole failure is "a declared gate stops gating, with no output anywhere".
Current blast radius — one live instance, already fixed; the rest is latent
Swept over every *.page.ts in the repo (export form classified from code with comments stripped):
| page | form | visibleWhen |
|---|---|---|
packages/platform-objects/src/pages/sys-user.page.ts |
raw literal | 1 — fixed in #11254 via P |
packages/platform-objects/src/pages/sys-organization.page.ts |
raw literal | 0 |
packages/platform-objects/src/pages/sys-position.page.ts |
raw literal | 0 |
packages/qa/downstream-contract/src/welcome.page.ts |
raw literal | 0 |
every page under examples/** |
definePage() |
7, all normalized |
So today nothing else is broken. What makes this worth a card is that the hazard is invisible at authoring time: the type accepts the bare string, tsc is green, every test is green, the gate farm is green, and the only symptom is a banner that stops gating in a shipped platform page. The next predicate added to a raw-literal platform page reproduces it exactly.
Options, not a recommendation to implement blind
- A. A gate. Assert that every page reachable from a platform package serves canonical envelopes for its
ExpressionInputSchemakeys — e.g. parse each exportedPagethroughPageSchemain a test and compare, which also closes the general "raw literal was never parsed" hole rather than the predicate half of it. - B. Route platform pages through
definePage(). Removes the fork at the source, but it changes the export shape of shipped platform pages and a strict parse may reject unrelated pre-existing looseness — needs its own measurement before anyone commits to it. - C. Author-side only (what Move both
record:alertgates onto thehas()-guarded nodevisibleWhen, as a CEL envelope (#9167) #11254 did): useP/ an explicit envelope on raw-literal pages. Correct per instance, but nothing stops the next one.
Related, upstream and separate: the renderer's bare-string back-compat window is objectui's, and closing it there would change verdicts for every un-normalized predicate in the wild — not something to ask for from this side.
Generated by Claude Code
Activity
os-zhuang commentedon Aug 23, 2026
Triage: half-labeled card completed —
domain:spec(kept) +pm:queue, typeTask.Scope as queued = option A only: a gate/test asserting that every page exported from a platform package serves canonical envelopes for its
ExpressionInputSchemakeys (parse each exportedPagethroughPageSchemaand compare) — closing the general "raw literal was never parsed" hole. Choosing A over B/C is verification strategy, PM discretion, not a maintainer decision: the live instance is already fixed (#11254 viaP), so what is missing is the thing that makes the next one impossible to ship silently — a gate does that without touching any shipped export shape.⛔ Option B (route platform pages through
definePage()) is explicitly not in this card: it changes the export shape of shipped platform pages and the card itself says a strict parse may reject unrelated pre-existing looseness — if the A gate's first run surfaces such looseness, report it as findings; a B migration would then be its own card with its own measurement. Option C is already done for the one live instance.Clause-② expectation: no — a test/gate addition changes no contract acceptance and expands no public surface. The objectui bare-string back-compat window stays untouched (their side, as the card notes).
Size/model suggestion:M, opus.Generated by Claude Code
os-steve commentedon Aug 23, 2026
Claim: PM seat
session_01T9cDbY2NBiVJWYx3BpWfH2(/pm-dispatch, spec lane) — dispatching to an os-dev subagent.Session: session_01T9cDbY2NBiVJWYx3BpWfH2
Branch:
claude/issue-11255-page-envelope-gateWorktree:
../objectstack-11255(created by the dev agent, per-task)Domain: domain:spec
File surface: new gate/test asserting canonical
ExpressionInputSchemaenvelopes on every platform-packagePageexport (option A per triage — options B/C are ⛔ out of scope). Expected to land inpackages/platform-objects(test over its own exports, importingPageSchemavia the bare workspace specifier) — NOT as apackages/spectest reading platform-objects sources, which would tripcheck:cross-package-test-inputs(#7802 class; it redded a sibling PR today).Container & model: os-dev subagent, opus (M per triage).
Clause-②: no — test/gate addition, no contract acceptance change, no public surface widened (triage concurs). If the diff unexpectedly reaches
packages/spec/src/**, the dev stops and reports (path limb).Serial constraints: none; surface disjoint from in-flight #11210 patch round and #11250.
Generated by Claude Code
os-steve commentedon Aug 23, 2026
os-dev-report
{ "issue": 11255, "status": "done", "branch": "claude/issue-11255-page-envelope-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/11481", "premise_still_valid": true, "summary": "Option A only. Added packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts — a gate asserting every Page this package exports serves canonical {dialect,source} envelopes at every ExpressionInputSchema position. Positions are identified BEHAVIOURALLY, not by name: each of three parse doors walks the authored object and its parsed counterpart in lockstep and flags one shape — the author wrote a string and the parse turned that same string into an envelope — so visibleWhen, the deprecated visibility alias, all three dialects and any expression key added later are covered with no edit to the test. Three doors because no single parse reaches every position: PageSchema (whole page), PageComponentSchema per walked component (reaches components nested inside the opaque properties bag that PageSchema walks past), and ComponentPropsMap[type] per properties bag (reaches props-declared keys like record:alert.properties.visible, which never reach ExpressionInputSchema at all). Component traversal reuses walkPageComponents from @objectstack/lint rather than reimplementing it. Each door's precondition is its own test, because a door that cannot parse reports nothing and that is indistinguishable from clean. Premise re-verified at merge base 8542bd4577: all three pages are still raw literals, #11254's envelope is in place on sys-user (P`…` at sys-user.page.ts:88), and no equivalent gate exists — lint's validate-visibility-predicates/validate-expressions judge the predicate's VALUE and run on the normalized/parsed tier, i.e. after the transform this card is about has already happened. Covered surface: platform-objects (3 pages, mandatory) yes; qa/downstream-contract NO — private:true, a frozen third-party-consumer fixture whose header states its purpose is to author naively, so it reaches no wire and a canonical-envelope demand would contradict its own contract; packages/cloud-connection NO but filed as #11480 — a FOURTH raw-literal page the card's *.page.ts sweep missed because it lives in cloud-connection-ui.ts, published and kernel-registered, same class, zero expression keys today. The gate does not trust filenames: it scans this package's own src/ for `export const X: Page =` declarations and asserts the barrel covers every one.", "tests": "All final readings on HEAD 1060c357c3 (git rev-parse --short HEAD from that run), serialized through scripts/pm/os-verify-lock.sh, exit codes captured before any pipe. Union re-run AFTER the final commit: typecheck exit=0 · platform-objects suite exit=0 (28 files / 454 tests passed; this file contributes 22) · check-cross-package-test-inputs --self-test exit=0 (104 self-test cases) and the gate itself exit=0 ('OK: 14 package(s) read outside themselves, all declared') · check-nul-bytes exit=0 · check:type-check-debt exit=0 ('33 ledger entries re-measured, none above its recorded number'). Gate family derived mechanically with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-rolled diff): all 14 derived families exit 0 — published-files, slot-lookup, test-source-alias, type-source-resolution, ci-filter-parity, plugin-teardown-shape, affected-docs, query-options-erasure, type-check-coverage, engine-double-contract, cross-package-test-inputs, where-matcher, i18n, nul-bytes (i18n needed the CLI built first; it exits 1 with 'PREREQUISITE NOT MET / Nothing was checked' otherwise — rebuilt, then OK across 9 packages). NEGATIVE CONTROLS (in-file, 6): a bare predicate on a top-level component is caught and the rendered failure names page + key path; the SAME predicate as an envelope yields zero findings; a bare predicate on a component nested inside properties is caught by door 2 while door 1 run in isolation is asserted to find NOTHING; a bare predicate inside the properties bag is caught by door 3 while doors 1+2 in isolation are asserted to find NOTHING; a bare predicate at the deprecated `visibility` key is caught and names the canonical key; and the shipped SysUserDetailPage deep-cloned with its envelope down-graded is caught, with the untouched export asserted still clean. REAL ABLATION on the shipped source (the operative acceptance criterion): sys-user.page.ts's predicate rewritten from P`…` to the bare string. NO REBUILD WAS NEEDED OR DONE, and that is a property of the resolution path, not an omission — the mutated page is read as SOURCE (the test imports ./index.js, which vitest resolves to src/pages/index.ts inside the package) and @objectstack/lint is aliased to ../lint/src/index.ts by this package's vitest.config.ts; the only dist-resolved import is @objectstack/spec, which the ablation does not touch and which was built beforehand. MUTATION CONFIRMED ON DISK before reading any result, anchored on the text being changed rather than a bare diff --stat: envelope-form `grep -c` 1 to 0 and bare-form 0 to 1 (the script aborts with 'ANCHOR MISS' at exit 3 if the anchor is absent, so a no-op mutation cannot be read as a result). Gate then ran RED at exit 1 naming 'SysUserDetailPage (sys_user_detail) · slots.alerts[0].visibleWhen / authored BARE: …'. RESTORE LEG confirmed by the same anchors in reverse (envelope-form back to 1, bare-form 0) plus an empty `git status --porcelain`; restore runs from a `trap … EXIT INT TERM` so a foreground-cap SIGTERM mid-mutation cannot leave the tree mutated. TWO GATES CAUGHT REAL DEFECTS IN MY OWN DIFF, both repaired at the source: (1) check-nul-bytes found a raw NUL byte the editor materialized into the new file — exactly the incident shape that gate documents; (2) check:type-check-debt --re-measure went red with '@objectstack/platform-objects: TEST_DEBT records 3 … now reports 4 (+1)'. That one matters beyond this PR: this package's tsconfig.json excludes **/*.test.ts, so the green `pnpm --filter @objectstack/platform-objects typecheck` I had already run said NOTHING about my test file — the ratchet is the only thing that reads that layer. The error was TS1470 ('import.meta' not allowed in files which will build into CommonJS output) on the source-scan seed; repaired the way this package's sibling managed-api-method-affordance-sweep.test.ts already documents (__dirname, which also remains a spelling check:cross-package-test-inputs resolves statically), NOT by touching the shrink-only ledger. Re-measured back to the recorded 3, all three pre-existing in feature-gate-guard.test.ts. NO pre-existing looseness in the platform pages: all 3 parse clean through PageSchema, all 34 walked components parse clean through PageComponentSchema, and every authored properties bag parses against its declared props schema — so nothing was folded or widened.", "open_questions": [ { "question": "NON-BLOCKING (filed as #11480, not gating this PR): how should the gate reach the fourth raw-literal platform page, packages/cloud-connection/src/cloud-connection-ui.ts's CloudConnectionSettingsPage? It is published and kernel-registered, so it is the same class, but it carries zero expression keys today and cloud-connection neither depends on @objectstack/lint nor wires the source alias check:test-source-alias expects.", "options": [ "A — give the detector a shared home (an exported helper in @objectstack/lint beside page-walk.ts), then a thin test per owning package. Widens @objectstack/lint's public surface, which is an API decision rather than a test one.", "B — a second self-contained copy of the detector in cloud-connection. Cheapest to land, and precisely the duplication page-walk.ts's own header records as having produced one dead rule.", "C — leave it: that page has no predicates, and the class is now gated where predicates actually live." ], "recommendation": "A, but as its own card — it is the only route that does not duplicate a walk this repo has already been burned by duplicating, and the surface question (one exported helper in @objectstack/lint) deserves a deliberate yes/no rather than riding in on a tests-only PR whose triage said no public surface would be widened." } ], "out_of_scope_findings": [ "filed as #11480: a raw-literal Page also ships from @objectstack/cloud-connection (cloud-connection-ui.ts) — the card's *.page.ts sweep missed it because the filename is not the discriminator; latent today (zero expression keys), same class, not fixed here" ] }Generated by Claude Code
os-steve commentedon Aug 23, 2026
PM verdict: ACCEPT — PR #11481
Reviewed the full test (613 lines, the PR's single file). Option A delivered above the card's sketch, and the deltas are all defensible:
visibility, all three dialects, and future expression keys with no edit, while materialized defaults are structurally unflaggable. Three doors with each door's necessity PROVEN by isolation negative controls (door 1 alone misses nested components; doors 1+2 miss props-bag keys), and each door's precondition its own test so silent coverage shrink is loud.export const X: Page =(filename explicitly not trusted — the discipline that found A raw-literalPagealso ships from@objectstack/cloud-connection— the*.page.tssweep missed it, so #11255's gate does not reach it #11480), with a ≥3 floor. Scan stays inside the package;__dirnameseed keeps it TS1470-safe and statically resolvable tocheck:cross-package-test-inputs— which is clean.private: true, reaches no wire); cloud-connection's fourth raw-literal page correctly NOT folded — the detector's shared home is an@objectstack/lintpublic-surface question, filed as A raw-literalPagealso ships from@objectstack/cloud-connection— the*.page.tssweep missed it, so #11255's gate does not reach it #11480 with options and a recommendation. That is the right split for a tests-only PR whose triage said no surface widening.skip-changesetmatches the tests-only convention (test(spec): derive the always-on mount boundary instead of pinningslice(0, 6)#11416/test(client): resolve every AUTH_ROUTE_LEDGER client name against a real client #11403 precedent, stated in the PR body). Nopackages/spec/src/**— clause-② not triggered, as declared at claim. No pre-existing looseness surfaced (3 pages / 34 components / all props bags parse clean), so nothing was folded.Landing: flipping ready now; enqueue on every-check-green.
Fixes #11255closes this card at merge; hygiene at the landing stroke. #11480's routing (option A as its own card) goes through triage as filed.Generated by Claude Code
feat(scripts): gate that kernel-reaching pages are declared in a disc…
feat(scripts): gate that kernel-reaching pages are declared in a disc…