Skip to content

A page authored as a raw Page object literal serves every ExpressionInputSchema key BARE, and the console silently routes bare predicates to its legacy evaluator #11255

Description

@os-zhuang

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 ExpressionInputSchema keys — e.g. parse each exported Page through PageSchema in 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:alert gates onto the has()-guarded node visibleWhen, as a CEL envelope (#9167) #11254 did): use P / 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

  1. added
    domain:specDomain lane — seat card indexed by label:pm:seat
    pm:queueReady for the PM dispatch loop
    on Aug 23, 2026
  2. added theissue type on Aug 23, 2026
  3. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage: half-labeled card completed — domain:spec (kept) + pm:queue, type Task.

    Scope as queued = option A only: a gate/test asserting that every page exported from a platform package serves canonical envelopes for its ExpressionInputSchema keys (parse each exported Page through PageSchema and 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 via P), 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

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 23, 2026
  5. self-assigned this
    on Aug 23, 2026
  6. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    Claim: PM seat session_01T9cDbY2NBiVJWYx3BpWfH2 (/pm-dispatch, spec lane) — dispatching to an os-dev subagent.
    Session: session_01T9cDbY2NBiVJWYx3BpWfH2
    Branch: claude/issue-11255-page-envelope-gate
    Worktree: ../objectstack-11255 (created by the dev agent, per-task)
    Domain: domain:spec
    File surface: new gate/test asserting canonical ExpressionInputSchema envelopes on every platform-package Page export (option A per triage — options B/C are ⛔ out of scope). Expected to land in packages/platform-objects (test over its own exports, importing PageSchema via the bare workspace specifier) — NOT as a packages/spec test reading platform-objects sources, which would trip check: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

  7. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    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

  8. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    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:

    1. Behavioural position detection (authored-side lockstep walk flagging "author wrote a string, parse made it an envelope") beats the card's parse-and-compare sketch: it covers 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.
    2. Population honesty: barrel exports cross-checked against a comment-stripped source scan for export const X: Page = (filename explicitly not trusted — the discipline that found A raw-literal Page also ships from @objectstack/cloud-connection — the *.page.ts sweep missed it, so #11255's gate does not reach it #11480), with a ≥3 floor. Scan stays inside the package; __dirname seed keeps it TS1470-safe and statically resolvable to check:cross-package-test-inputs — which is clean.
    3. Covered-surface judgment accepted: qa/downstream-contract excluded for a contract-grounded reason (frozen naive-authoring fixture, private: true, reaches no wire); cloud-connection's fourth raw-literal page correctly NOT folded — the detector's shared home is an @objectstack/lint public-surface question, filed as A raw-literal Page also ships from @objectstack/cloud-connection — the *.page.ts sweep 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.
    4. Real ablation on the shipped source with on-disk anchor checks and a trap-guarded restore; plus the two gates that caught real defects in this diff (NUL byte; TS1470 via the debt ratchet — repaired at source per the sibling test's documented spelling, ledger untouched).
    5. skip-changeset matches the tests-only convention (test(spec): derive the always-on mount boundary instead of pinning slice(0, 6) #11416/test(client): resolve every AUTH_ROUTE_LEDGER client name against a real client #11403 precedent, stated in the PR body). No packages/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 #11255 closes 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

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

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seattests

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions