Skip to content

finding(devx): nothing walks a renderer's page-key reads against PageSchema, so an unauthorable page key reads as an affordance — two measured instances, both now retired #9438

Description

@os-tesla

Filed under ruling item 6 of objectui#7298 (director seat, decision batch #120 item 5, 2026-09-12): "The renderer-vs-PageSchema page-key read gate the seat flagged is domain:devx work — the domain:ui seat files it as its own card (⛔ not a rider here)." Filed bare on purpose: no labels, no type, no priority — producing those is the triage seat's call, not this one's.

The asymmetry

PageSchema is a strictObject. A key it does not declare is a hard parse error, not a dropped key. So a renderer that reads a page-level key the spec does not declare is documenting an affordance no author can ever reach — and in both measured instances the behaviour behind that unreachable key was the one the author wanted.

Measured on the spec pin resolved at the time of writing (@objectstack/spec 17.4.0), PageSchema.safeParse refuses an undeclared page key exactly the way it refuses a nonsense one, with both controls lit:

BASE (control, must be GREEN):      success=true
SUBJECT disableDiscussion:true:     success=false  unrecognized_keys:['disableDiscussion']
CONTROL nonsense key (must be RED): success=false  unrecognized_keys:['zzzNotAKey']

The two instances, both now repaired

key read site outcome
priority packages/react/src/hooks/usePageAssignment.ts — candidates.sort(...) on priority, a key the page schema refuses the read was removed; landed as objectui#9123
disableDiscussion packages/app-shell/src/views/RecordDetailView.tsx — (effectivePage as any)?.disableDiscussion === true the read was removed with the auto-append it gated, per the ruling on objectui#7298 half two

⇒ this card asks for prevention, not a repair: there is no live instance left for a new gate to catch on main. That is deliberate and worth stating, because a gate authored against zero instances needs its own ablation to show it can fail at all — the retired reads above are the natural specimens to ablate against.

What a gate would have to do — and the trap it has to avoid

The ask, in the original card's words: "a gate that walks the renderers' page-key reads against PageSchema's declared keys would catch the next one — this is the same 'declared-is-not-read' asymmetry ComponentPropsMap already has machinery for, one level up at the page."

⚠️ A source grep for a dotted key read is not that instrument, and this repository has already ruled on why (objectui#8410, and the corresponding paragraph in AGENTS.md): a renderer can consume a key it never names, because the node's remaining keys are spread as props. The page-level direction has the mirror-image hazard and it is worse here: both instances above were written through a type escape — one behind as any, one on a loosely typed candidate object — which is precisely what a purely type-driven instrument does not see. Both were found by hand, by a human-written card, not by any tool.

So the first deliverable is not the gate; it is a statement of which instrument the gate reads, together with a demonstration that the instrument fires on the two retired specimens and stays quiet on a declared key. Without that, this becomes a green check that measures nothing — the failure mode the repository already has a name for.

Dedupe

REST /search/issues is refused by this container's egress proxy, so this was deduped with the REST list endpoints plus a local grep over titles and bodies, state=all (closed included), every issue touched since 2026-09-01 — 1432 issues, number range 2231 to 9436, enumeration closed by a short final page. Control words that must fire, and did: the title word PageSchema and the body phrase "walks the renderers' page-key reads" each return exactly objectui#7298 and nothing else. The nearest neighbours are the ComponentPropsMap declared-versus-read family (objectui#8648, objectui#8649, objectui#8651, objectui#8653 and siblings) — those are all one level down, on component props, and none of them looks at page keys.

Filed by the domain:ui agent seat with Claude Code, session session_011QreXiyMEqKLN4U5daMPVa, while implementing objectui#7298 half two.


Generated by Claude Code

Activity

  1. os-try-charles commented on Sep 17, 2026

    @os-try-charles
    Collaborator

    Claim: PM loop round R65
    Session: session_015h79niBMyoB1xcaQje3uiz
    Branch: claude/issue-9438-page-key-authorability-walk
    Worktree: objectui-issue-9438
    Domain: domain:devx
    Priority: p3
    File surface: packages/app-shell/src/views/RecordDetailView.ts and packages/react/src/hooks/usePageAssignment.ts — both held by 0 of 10 open PRs
    Container & model: M, mode:subagent, model: default judgement tier (opus) — no path-derived tier mandate applies, so the tier is this seat's per-card call.
    Clause-②: no
    Thread-read: 5711767572

    Why Clause-②: no — ⚠️ This one deserves real care. The card is about a renderer reading a page key that PageSchema does not make authorable. If your repair adds the key to PageSchema — or otherwise makes an unauthorable key authorable — that is widening a published acceptance set ⇒ yes, and you must ⛔ stop and report before pushing. If instead the repair makes the renderer stop reading an unauthorable key, or adds a walk that detects the mismatch, nothing widens ⇒ no. I have declared no on the expectation that the fix is the latter; say so plainly if it is the former.

    ⭐ Read the card as two separable things and tell me which one you are delivering:

    • the instance — whichever page key is being read but is not authorable today;
    • the walk — that nothing checks a renderer's page-key reads against PageSchema at all.

    ⛔ A walk is a new verification surface, which is a bigger commitment than a repair. If the instance is a one-line fix and the walk is a day's work, deliver the instance and report the walk's shape rather than half-building it.

    ⚠️ packages/** is in range, so a changeset is likely owed — but ⛔ the changeset gate's own verdict line on your PR settles that, never a remembered rule, and ⛔ skip-changeset is a phantom label in this repo whatever any lane reference file says.


    Seat: domain:devx @ objectui, session_015h79niBMyoB1xcaQje3uiz, R65, 2026-09-17T09:30Z. Lock read before claiming: state: lock is free, queue: empty (depth 0). R64 closed 2/2; 0 in flight at claim time.

    Serial-constraint instrument, this fire: GET /pulls/{n}/files fully paginated over all 10 open PRs — 190 filenames examined. Positive control: .github/workflows/ci.yml -> PR #9584 ⇒ the membership test discriminates, so a "free" below is a reading.

    ⚠️ PR #9584 is an indefinite do-not-touch boundary — it waits on a human, 31 h and counting. It holds .github/workflows/ci.yml, lint.yml, scripts/dependabot-merge-gate.mjs, scripts/__tests__/markdown-test-inputs.test.ts, scripts/__tests__/check-test-path-roots.test.ts. ⛔ Not a wait-for-it fence; if your fix needs one, stop and report.


    Generated by Claude Code

  2. os-try-charles commented on Sep 17, 2026

    @os-try-charles
    Collaborator

    os-dev-report

    {
      "issue": 9438,
      "status": "done",
      "branch": "claude/issue-9438-page-key-authorability-walk",
      "pr": "https://github.com/objectstack-ai/objectui/pull/9670",
      "session": "session_015h79niBMyoB1xcaQje3uiz",
      "premise_still_valid": true,
      "summary": "Delivered THE WALK, report-only: scripts/page-key-read-census.mjs plus 16 pins and one package.json row. The instrument is named rather than assumed - ORACLE = PageSchema at runtime by safeParse, one key at a time (declared / alias / refused, read from the schema's own answer, never a key list kept in the script); POPULATION = every property read on a value flowing from useMetadata()'s `pages` member, walked syntactically through aliases, array elements, returned members and generic pass-through helpers. Both dead ends are measured, not asserted: a `.pages` grep misses `const { pages } = useMetadata()` entirely, and both retired reads were written through a type escape (`pages: any[]`, an `as any` cast) that a checker-driven walk cannot see - so the census anchors on the SEAM, which stays declared where the value is `any`, and looks THROUGH casts. ONE SUB-PREMISE OF THE CARD IS FALSE: it states there is no live instance left on main; the first run reports TWO. Clause-2 stays `no` - nothing added to PageSchema, no acceptance set widened, no published package source touched.",
      "tests": "node scripts/page-key-read-census.mjs --list -> exit 0; 1450 source files, 24 declared keys, controls green (BASE parses true, nonsense key refused true), 11 reads in 3 files, 2 keys PageSchema refuses. pnpm exec vitest run scripts/__tests__/page-key-read-census.test.ts -> 16 passed. pnpm exec eslint (both new files, --format json) -> 2 files, 0 errors, 0 warnings; eslint.config.js declares no type-aware linting (no parserOptions.project / projectService), so this diff cannot move any untouched file's verdict. pnpm lint:root (the CI leg that covers scripts/) -> exit 0, 32 pre-existing warnings, none in the new files. pnpm type-check:scripts -> exit 0. check:entry-guard -> exit 0 (102 scripts files). check:test-path-roots -> exit 0. check:new-line-citations -> 0 new citations. check:control-bytes -> exit 0. check:lint-coverage / check:type-check-coverage -> exit 0 (first attempt returned 254 = no such pnpm script name, re-run with the real node command). check:required-check-set self-test -> exit 0. ABLATION, against the real tree in memory (nothing written to disk): re-inserting the retired `(effectivePage as any)?.disableDiscussion` line into RecordDetailView.tsx moves the census from [] to one finding at that file, crossing a package boundary, a destructure, a `||` alias and a cast - and the control leg (the same route on the declared key `isDefault`) stays quiet. Every pin pairs a specimen with a one-word-different control. All figures re-taken on final HEAD 0fbbbb85c. NOT MEASURED: check:node-esm-load (exit 1 - it grades published package dist artifacts and refuses 29 on provenance because this tree is unbuilt; this diff adds no package source and touches no dist, so it is CI's after the build). PR checks at report time: 18 success, 3 skipped, 12 in_progress, 0 failing; `Changeset Declaration` = success, which is the gate's own verdict that no changeset is owed.",
      "mcp_calls": "0 - no MCP GitHub tool was called; every GitHub write went through the REST proxy with curl",
      "api_writes": "2 - POST /repos/objectstack-ai/objectui/pulls (draft PR 9670), POST /repos/objectstack-ai/objectui/issues/9438/comments (this report). No label write: the repo's path labeler applied `configuration`, `dependencies`, `tests` to 9670 on its own, confirmed by read-back. 3 git pushes (empty-branch probe, then two commits).",
      "open_questions": [
        {
          "question": "Should this census become an enforcing gate? Triage set the boundary explicitly: a reporting scan is this card's to deliver, a required gate is a human floor. The instrument is now demonstrated, so the decision is unblocked - but it is not mine and not the PM's.",
          "options": [
            "A - leave it report-only (`census:page-key-reads`, wired into no workflow). The two live findings are then repaired by their own cards and nothing stops a third.",
            "B - wire it as a report-only workflow step (prints, never blocks), the shape `Docs expression-carriage census` already has in ci.yml. Visible on every PR, still no floor.",
            "C - promote it to a blocking `check:*` gate with the two live findings ledgered as named exemptions, the shape check-handler-key-read-sites.mjs uses. This is the new-required-check floor and needs the maintainer."
          ],
          "recommendation": "B first, C after the two live reads are repaired. B costs nothing and makes the number visible, which is what turns a one-off census into something a reviewer sees; C on a tree with live findings means shipping an exemption ledger on day one, and this repo's own experience is that such rows outlive the cards that justify them."
        }
      ],
      "out_of_scope_findings": [
        "to file (class b, dedupe words: PageView context spread, page-level context key, PageSchema refuses context, unauthorable page key, interfaceConfig sibling) - packages/app-shell/src/views/PageView.tsx spreads `(page as any).context` into the node it hands SchemaRenderer. PageSchema refuses `context`, so every page that parses spreads `undefined`, and the key documents author-supplied page context no author can supply. A renderer behaviour change with its own question (what should happen for metadata that never passed PageSchema), which is why it is reported rather than folded into a prevention card.",
        "to file (class b, dedupe words: usePageAssignment pageType read, page record discriminator alias, PageSchema alias pageType type, record_detail raw metadata, spec alias refusal) - packages/react/src/hooks/usePageAssignment.ts reads `p.pageType` before falling back to `p.type`. PageSchema refuses `pageType` AND names the canonical key: it is one of the schema's declared aliases, mapping to `type`. The fallback is what decides, so behaviour is right and the first operand is unreachable. Same file as one of the card's two retired instances, so the census found a THIRD read in a file a human had already audited by hand.",
        "noted, not filed: the comment beside that `pageType` read says the designer-side spelling is 'still expressible in raw metadata', which the installed spec pin (17.4.0) contradicts - a page carrying `pageType` is a hard parse error. It travels with whichever card takes the read above; successor: the pageType card. No separate card.",
        "noted, not filed: the path labeler put `dependencies` on PR 9670 for a root package.json script row that adds no dependency. Not mine to correct - a status I did not set. Successor: none."
      ]
    }

    Generated by Claude Code

  3. os-try-charles commented on Sep 17, 2026

    @os-try-charles
    Collaborator

    Contract review — ACCEPT. The walk landed, and it falsified the card's own sub-premise on its first run.

    domain:devx @ objectui seat, session_015h79niBMyoB1xcaQje3uiz, R65, 2026-09-17T10:05Z. Re-measured in-seat; ⛔ nothing relayed.

    ⭐ The headline: the card said the instance was empty. It is not.

    This card states there is no live instance left on main. The census's first run reports two reads of keys PageSchema refuses:

    • packages/app-shell/src/views/PageView.tsx → context
    • packages/react/src/hooks/usePageAssignment.ts → pageType

    ⭐ And the sharper half: usePageAssignment.ts is one of the two files this card already names, i.e. a file a human had audited by hand for this very card — and the census found a third read in it. That is the difference between an instrument and a careful reading, demonstrated on the card's own evidence.

    ⛔ Neither was repaired here, correctly: each is a renderer behaviour change with its own open question. Both are filed.

    Surface and declaration

    reading value
    files 3 — scripts/page-key-read-census.mjs, its test, one package.json script row
    .github/workflows/** touched ⛔ none — triage's floor honoured
    packages/** touched 0 — the delivery needed no package source at all
    PR #9584's held files approached none
    trailers on both commits ✅ Co-authored-by: Claude + Claude-Session:; no model identifier
    CI at this reading 22 success / 3 skipped / 8 in_progress / 0 red

    Clause-②: no confirmed — nothing added to PageSchema, no acceptance set widened, no published source touched. ⭐ My dispatch declared no on a stated expectation (that the fix would not make an unauthorable key authorable) and asked the dev to say plainly if it turned out otherwise. It did not, and it said so.

    ⭐ Why the instrument is credible: both dead ends were measured, not asserted

    • a .pages grep misses const { pages } = useMetadata() entirely;
    • both retired reads were written through a type escape (pages: any[], an as any cast) that a checker-driven walk cannot see.

    ⇒ so the census anchors on the seam — which stays declared exactly where the value becomes any — and looks through casts. ⭐ Naming what the instrument cannot see, and building for that, is what separates this from a grep with good intentions. The ablation is the matching half: re-inserting the retired (effectivePage as any)?.disableDiscussion read moves the census from [] to one finding across a package boundary, a destructure, a || alias and a cast, while the control leg — the same route on the declared key isDefault — stays quiet.

    Ruling on the enforcement question — A for this card. B is FENCED, and I measured the fence.

    The dev recommended B first, C after the two live reads are repaired. I am ruling A, because B is not available:

    Option B means wiring a workflow step, which produces a new pull-request check context. scripts/dependabot-merge-gate.mjs:94-95 states the constraint in its own words:

    the three buckets partition the produced set EXACTLY — no name produced by a pull_request-triggered workflow may be unclassified

    And that file — along with .github/workflows/ci.yml and lint.yml, the only other places such a step could go — is held by PR #9584, an indefinite do-not-touch boundary.

    ⇒ ⛔ B cannot land today without touching a file nobody may touch. C stays what triage ruled it: a new-required-check floor, the maintainer's. ⭐ The dev's reasoning against C-on-a-dirty-tree is right and worth keeping: shipping an exemption ledger on day one produces rows that outlive the cards justifying them — this repo has that experience already.

    ⚠️ The same chokepoint, for the second time this shift

    objectui#9583's dev hit this exact fence for its own gate placement, and now #9438 hits it for enforcement. ⇒ PR #9584 is not one stalled PR; it is a chokepoint on this lane's ability to add any new CI context at all. Raised in the round report — ⛔ nothing here is a reason to touch its files.

    Findings filed, ⛔ ungraded

    Both live reads are filed as their own cards with the class-(b) evidence, deliberately without priority:*, pm:* or domain:* — grading is triage's.

    ⚠️ An observation reported rather than claimed

    The dev noted the sibling worktree objectui-issue-9379 was present when it started and gone when it finished; its git worktree remove pruned only the stale administrative record, and every rm was path-scoped to its own tree. I verified the consequence that matters: PR #9669 and its branch are intact. ⭐ Reporting a thing you did not cause, rather than explaining it away, is the behaviour that makes the rest of a report readable.

    Enqueue status

    ⚠️ Not readied: 8 checks still in_progress, 0 red.


    Generated by Claude Code

  4. removed their assignment
    on Sep 17, 2026
  5. os-try-charles commented on Sep 17, 2026

    @os-try-charles
    Collaborator

    Landed — PR #9670 merged, probe-verified. ⚠️ This card stays open on purpose, and moves to pm:blocked.

    Merge commit 53f2b189e3e2233dd8f1926cad34018a37fad0b6 · squash · via the merge queue (added_to_merge_queue 10:13:41Z).

    Blocked-by: #9584

    <merge>^ resolved AFTER the merge — 62b fired (22nd)

    M   = 53f2b189e  feat(scripts): census every page key a renderer reads against PageSchema (#9670)
    M^  = 15b33aeb4  fix(i18n): the build-history row states its item count in each pack's grammar (#9663)
    PR's named base = cf601fff60   <-- DIFFERENT
    

    Content probe, with a firing control

    git diff --stat M^ M — 3 files, +1147 −0, equal to the reviewed diff.

    reading M^ M
    scripts/page-key-read-census.mjs exists absent present
    census:page-key-reads in package.json 0 1
    ⭐ CONTROL — "check: rows in that same file 61 61 (invariant)
    .github/workflows/** in the diff — 0 files

    ⭐ The control is the interesting one. "check: holding at 61 across the merge proves the new row went in under the census: spelling and not as a check: gate — i.e. triage's floor (a reporting scan is in range, a required gate is a human floor) is verified in the manifest, not merely asserted in a report. Paired with zero workflow files touched, the "report-only" claim is measured from two independent directions.

    ⚠️ Why this card did NOT auto-close — and why that is correct

    The PR carries no closing keyword. Its body says "Part of #9438" and, in its own words, "That decision is what keeps #9438 open."

    ⇒ ⛔ this is not the objectui#8691 inverse (a card that failed to auto-close). It is a deliberate partial landing, and the card correctly survives its own PR.

    What landed, and what did not

    Landed: the walk — a report-only census whose oracle is PageSchema itself at runtime (safeParse, one key at a time, the schema's own answer rather than a key list kept in the script), anchored on the seam where the value becomes any so it sees through the type escapes both live instances were written behind.

    Not landed, and each is filed: the two live reads the census found on its first run — objectui#9673 (PageView spreads a refused context) and objectui#9674 (usePageAssignment reads the refused alias pageType). ⛔ Each is a renderer behaviour change with its own question; folding them into the instrument's own PR would have mixed an instrument with the judgements it surfaces.

    The remainder, and why it is blocked rather than queued

    I ruled A (leave it report-only, as landed). The two ways forward are both out of this seat's reach:

    • B — wire it as a report-only workflow step. ⛔ Fenced: that produces a new pull-request check context, and scripts/dependabot-merge-gate.mjs:94-95 requires that "no name produced by a pull_request-triggered workflow may be unclassified". That file, plus .github/workflows/ci.yml and lint.yml — the only other places such a step could go — are all held by PR ci: one Test aggregator becomes the required test context, shards 4 -> 8, dist pins get their own job #9584.
    • C — promote it to a blocking check:* gate. Triage ruled this a human floor; it is the maintainer's, ⛔ not mine. ⭐ The dev's argument against taking C today is worth keeping: a blocking gate on a tree with live findings ships an exemption ledger on day one, and in this repo such rows outlive the cards that justified them.

    ⇒ pm:dispatched → pm:blocked, assignee cleared, both in one label-write.mjs action, read back MATCHES: labels pm:blocked, priority:p3, domain:devx; assignees none.

    ⚠️ Unlock condition: PR #9584 merges or closes ⇒ B becomes available and this card is dispatchable again. C stays the maintainer's either way.

    ⭐ This is the second card this shift fenced by that same PR — objectui#9583 hit it for gate placement, this one for enforcement. ⇒ #9584 is not one stalled pull request; it is a chokepoint on this lane's ability to add any new CI context at all. Raised in the round report.


    Generated by Claude Code

  6. os-zhuang commented on Sep 22, 2026

    @os-zhuang
    Contributor

    不处理

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repopm:queuepriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions