Repository navigation
page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling) #6252
Description
Activity
os-support-ai commented
on Aug 26, 2026 CollaboratorAuthorMore actionsMoved
pm:queue→pm:blockedby thedomain:uiseat (sessionsession_011SfZeFWrhGLHmfq61xbz4q, round 32). Not a downgrade — this card was selected for dispatch and stopped at its own gate.Blocked-by: objectstack-ai/objectstack#12580is now line 1 of the body, so the reverse index picks it up and the unlock sweep returns it automatically.Why. This card's Step 1 gates the rest by its own wording, and it is a hotcrm measurement: whether any of the 16 inline
record_headerActionDefs carries page-level customization an id-resolved action cannot express. This seat cannot read that repo. Measured rather than assumed — unauthenticated REST returns HTTP 403 forobjectstack-ai/hotcrmagainst a HTTP 200 control onobjectstack-ai/objectuiin the same call, and the session's repository listing returns no match.Dispatching anyway would have burned a developer on a card whose first instruction is an ⛔ stop, and the likely outcome is a premise-false report rather than a fix. A repo this seat cannot read is not a repo it has read and found clean.
What was filed instead: objectstack#12580, carrying the question, the reason this seat cannot answer it, and ready-to-run commands for whoever holds hotcrm. It was filed unlabelled —
domain:*,type, grading andrepo:*routing belong to the triage seat, not to this one.Note for whoever picks this up when it unblocks: a no to that question is worth more than a yes. It is evidence that reopens the objectstack#11592 ruling, and the card says explicitly not to absorb it with an override semantic. Route it back to objectstack#11592 rather than designing around it here.
⭐ One reading this seat deliberately did not take: the card also says "keep the existing object-shape handling working during the transition if it is cheap", which could be read as making the renderer change safe to land ahead of the measurement, with the equivalence question gating only hotcrm's downstream migration. That is a plausible reading, but the card says Step 1 "gates the rest" in those words, and reinterpreting another seat's explicit gate is not this seat's call. If the reader of objectstack#12580 thinks the implementation can proceed in parallel, say so there and this card can be unblocked without waiting for the full measurement.
Generated by Claude Code
Claim —
domain:uiexecution seat, dispatch round R18- Session:
session_012wwHa4aaFybxXrfmfHioDM - Branch:
claude/issue-6252-page-header-action-ids - Worktree: dedicated, off
origin/main(one repo —objectuionly; no sibling repo is edited by this card) - Domain:
ui - Container & model: M implementation + rendering proof,
mode:subagent - Serial constraints cleared: no open PR and no other claim touches
page:headerorrecord-quick-actions.tsx. The three PRs this seat currently has in flight (test(plugin-grid): render what the inline lookup picker actually receives #7167plugin-grid, docs(plugin-timeline): the gantt row walk's non-total reads become a measured enumeration, and the class claim is corrected #7168plugin-timeline, fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw #7169plugin-charts) are in disjoint packages.
Clause ② — assessed NOT engaged, and this is the load-bearing part of the claim
The spec already declares
PageHeaderProps.actionsasz.array(z.string()). This card does not widen that surface and does not change what the contract accepts or rejects — it makes the renderer honour a declaration that is already public. That is enforce-side ADR-0049 work, belowCONTRACT_REVIEW_TIER.⛔ It stops being that the moment anyone edits the declared type or its zod mirror (e.g. to a
string | ActionDefunion to "legalise" the object shape the renderer accepts today). That edit is Clause ② and is fenced out of this card in the dispatch order — the ruling's contract of record is ids, and the object shape survives as renderer tolerance only, exactly as it does today.Gate status carried into dispatch
Step 1 is answered PASS — objectstack#12580 comment 5486736502, hotcrm @
8c40737: all 16 inlinerecord_headerActionDefs are the same JS object as their registered counterparts, so property divergence is structurally zero. The ⛔ stop branch does not fire. That measurement's per-def table is handed to the implementer as the reference population.One correction carried forward so the implementer does not re-derive it: the measurement's own "overall answer: NO" was mapped onto the wrong question and was corrected in objectstack#12580 comment 5486750108. What it observed — that swapping objects for id strings deletes every header button today — is objectstack#11592's premise, i.e. the defect being fixed, not evidence against the ruling. ⛔ Do not treat it as a reason to stop.
Generated by Claude Code
- Session:
os-dev-report
{ "issue": 6252, "status": "done", "branch": "claude/issue-6252-page-header-action-ids", "pr": "https://github.com/objectstack-ai/objectui/pull/7180", "premise_still_valid": true, "summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.", "tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' -> Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' -> 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites -> 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) -> 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.", "mcp_calls": "11 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments. Bulk reads went to git and to the zero-quota web payload channel instead.", "open_questions": [ { "question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.", "options": [ "A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory", "B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)", "C — do nothing; each dev re-discovers it per run" ], "recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns." } ], "out_of_scope_findings": [ "filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.", "filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin." ] }
Generated by Claude Code
✅ LANDED — PR #7180 squash-merged to
main(8ec11e14f)The objectstack#11592 ruling (maintainer 2026-08-25, 「全部同意」 on recommendation B) is implemented. Verified by content with live controls:
probe result SUBJECT — useMetadataIteminrenderers/layout/containers.tsx4 SUBJECT — new pin packages/components/src/__tests__/page-header-action-ids.test.tsx1 (control: 108 pre-existing suites in the same listing) SUBJECT — the unresolved-id warning 1 CONTROL — record-quick-actions.tsxstill resolves throughuseMetadataItem2 Clause ② fence — PageHeaderPropsdeclared in objectui's own typesnot present — it stays upstream in @objectstack/spec, untouched⭐ That fourth row is the one that matters: the control is not merely "something still exists", it is the same resolver entry the new code routes through.
useMetadataItemhitting in both files is what makes "no second resolver" a measurement rather than an assurance.What shipped
actionselements resolve as declared action ids against the object's own metadata, through the entryrecord:quick_actionsalready uses. Normalisation sits at the top of the pipeline, so the existing single filter chain —actionRendersAtplacement,requiredPermissions,visible/hidden,order, inline/overflow split — runs unchanged over uniformly-shaped defs. That is what makes the equivalence claim measurable rather than asserted.Inline
ActionDefobjects keep working per element, so a half-migrated['convert', {def}]array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An unresolvable id renders nothing and warns once, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. Silently dropping it would have reproduced the silent-loss class this seat filed three refusals against this week.Clause ② fence held: the spec type and its zod mirror are untouched;
actionsstaysz.array(z.string()). The renderer caught up to a declaration that was already public.The equivalence proof, and why its control is load-bearing
The same action metadata authored twice — once as ids, once as inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including
qualify(order 1) rendering beforeconvert(order 2) — the reverse of the order both authorings list them in.Ablation:
Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.⭐ A2.2 was my low-confidence assumption and confirming it closed a named risk. I flagged a possible second
page:headermount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration,packages/layout/src/index.tsdeliberately does not re-register it and says so, and the legacypage-headeralias already resolves ids by delegating torecord:quick_actions. So the sibling that already implemented this ruling was the alias, not a rival canonical mount.⚠️ Acceptance criterion 3 was measured, and deliberately NOT committed"The id path carries no
body.sourceinto the built artifact" was proven against the real built bundle, with a live control (removing one import failsexpected undefined to be truthy, proving the passing run measured the bundle). It was then not committed, because turbo'stesttask isdependsOn ['^build']— dependencies only — so a committed dist-importing test would beMODULE_NOT_FOUND/ NOT MEASURED in CI.⭐ A test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Committing it would have put a permanently-void assertion into the suite under the name of this card's hardest criterion. Filed as #7183; the committed half is the pin that can fail — "never writes a resolved def back onto the authored node" — with the object-shape authoring as its control, since the inline form genuinely does drag the handler body into what it serializes.
⚠️ #7182 is the completion condition of this migration, not a loose endThe action-id lookup now exists in two places and they disagree on mixed arrays:
record:quick_actionsswitches on the whole array (every element a string),page:headernormalises per element. During the migration this card opens, one authored array means two things depending on which renderer draws it.My "no second resolver" fence held — the metadata read is shared. What diverged is the array normalisation policy, which is a different thing and the fence did not cover it. Unlike most findings this one was introduced here rather than discovered, so I am grading it as this migration's completion condition rather than an ordinary finding. Declining to unify it in this PR was still right: it would change
record:quick_actionsbehaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling.Downstream
hotcrm migrates its 16 inline objects to id references now that this has landed — its lane's follow-up, and the conformance-sweep exemption naming objectstack#11592 comes off then. hotcrm#1279 unlocks via the parent. ⭐ The gate that blocked this card for six days (objectstack#12580) is what made that safe: all 16 defs are the same JS object as their registered counterparts, so property divergence was structurally zero.
Clearing
pm:dispatched.
Generated by Claude Code
Blocked-by: objectstack-ai/objectstack#12580
Implementation of the objectstack#11592 ruling (maintainer, 2026-08-25, verbatim 「全部同意」 on recommendation B; full record = objectstack#11592 comment 5405065025). Filed by PM session
session_01KWRU3s15AJz7PGW7a7wdChper the confirmed checklist. Parent: objectstack#11592 (tracking).Step 1 — the equivalence measurement, and it gates the rest: compare the exemplar's 16 inline ActionDef objects (hotcrm, 4 record pages, all
locations: ['record_header', …]) against their object-level registered actions. If any inline def carries page-level customization an id-resolved action cannot express, ⛔ stop and report back to objectstack#11592 — that evidence reopens the ruling; do not silently absorb it with an override semantic.Content: the canonical
page:headerrenderer resolvesPageHeaderProps.actionsas action ids against the object's own metadata — the contract the spec declares (z.array(z.string())) and two sibling renderers already implement (layout:page-headerdelegating torecord:quick_actions; the resolver pattern lives inpackages/plugin-detail/src/renderers/record-quick-actions.tsx). Reuse that pattern, do not invent a second resolver. Keep the existing object-shape handling working during the transition if it is cheap; the contract of record is ids.Acceptance criterion: a
page:headerauthored with action ids renders the same buttons the object-shape authoring renders today (filtered byactionRendersAt(a, 'record_header'), honouringrequiredPermissions/visible/order); the renderer's tests gain the id-shaped cases; the id path carries nobody.sourceinto the built artifact.Downstream: hotcrm migrates its 16 inline objects to id references once this lands (its lane's follow-up; the conformance-sweep exemption naming objectstack#11592 comes off then); hotcrm#1279 unlocks via the parent.