ci(gate): diff each action renderer's forward whitelist against the keys the runtime reads (#4050, #4192) - #4207
Merged
Conversation
…e runtime reads (#4050) Every action renderer hands the ActionRunner an explicit key WHITELIST rather than the action itself. That is deliberate — a key no renderer honours must not look wired — but its cost is that a NEW key stays invisible until five separate lists are edited, and nothing fails while they are not: the key parses, publishes and reads as honoured while the payload is dropped one hop before the runner. Six instances shipped that way, each found by a human reading the lists side by side (bodyExtra objectstack#6837, bodyShape #6938, resultDialog objectui#3646, openIn / locations / undoable, label + description #4192). objectstack#6975 stalled because one half of the diff looked underivable: a grep over the runner and the console handlers cannot separate a body-path key a renderer must forward from a mechanic the runner resolves itself. Two changes make it derivable — read the consumers with the compiler API, scoped to the binding the def arrives as (so `a.locations` off the AUTHORED list is not confused with `action.target` off the FORWARDED def), and intersect that with what the surface may be AUTHORED with: owed(surface) = authorable(surface) ∩ runtime-read − retired Both inputs come from their real declarations — the spec's own zod shapes plus `@object-ui/types`' renderer view, and the consumers' ASTs — so neither can be a stale hand copy. The surface-class split #6975 flagged is derived rather than registered: `element:button` gets `InlineActionSchema`'s narrower vocabulary and is green with no exemption at all. What is left over is real, and is declared in two ratcheted tables: JUSTIFIED ("correctly omitted" — unreachable behind a `rowRecord` guard, or consumed by the renderer itself) and KNOWN_GAPS ("really dropped, filed, not fixed here", #4202). Each entry carries file:line evidence, and an entry that excuses nothing is itself a failure, so neither table can outlive the code it excuses — the answer to #6975's objection that the registry becomes the drift-prone list one level up. Extraction failure is RED, never a silent pass, per the ruling: an unresolvable spread, a renamed binding, a moved `execute()` site, a missing consumer, an empty owed set. Each throws rather than returning a clean verdict, and each is driven from a synthetic repo in the pin test, so no fix that makes the real tree green can leave the rule unexercised. Wired as `pnpm check:action-forward-parity` in ci.yml's `type-check` job beside `check:spec-symbols` — it needs the install (it resolves @objectstack/spec) but nothing built. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
…title a param dialog and reveal a result (#4192) Which renderer a declared action gets is decided by `action:bar`'s `maxVisible` split (3 desktop, 1 mobile) and by `systemActions`, which are always in the overflow menu. The four declared renderers' forward whitelists had drifted, so the same declaration behaved differently by viewport width. `label` and `description` are what the console's param-collection handler titles its dialog from (`title: action?.label || action?.title`, `description: actionDescription(…, action?.description)` — useConsoleActionRuntime.tsx:205-207). Dropped, an overflow action with declared `params` opened a dialog titled "Action parameters" while the SAME declaration rendered inline named itself. #4192 measured this on `action:menu`; the gate from the previous commit found it on `action:icon` and `action:group` too, and found that those two also drop `resultDialog` — the one-shot reveal spec whose loss is objectui#3646, still live on two of the four surfaces. `undoable` and `recordIdField` are deliberately NOT added, which re-scopes half of #4192. Both are read only under a `rowRecord` guard (useConsoleActionRuntime.tsx:377, :398), and `rowRecord` is `params._rowRecord` — written only by the spread-based hosts (DeclaredActionsBar.tsx:215, RelatedRecordActionsBridge.tsx:164, ObjectGrid.tsx:1721/:2034, containers.tsx:1287-1289), none of which dispatch through these renderers. `action:button` forwards them on this path INERTLY, so the menu was never "losing Undo" relative to the button; adding them would have shipped a second inert copy. That verdict is carried, with its evidence, in the gate's JUSTIFIED table, which fails if it ever stops being true. The behavioural pin drives the real renderers through the real runner and asserts what the param-collection handler is HANDED, so the two halves fail for different reasons: delete a key from a whitelist and both the gate and the pin go red; break the runner's param-collection dispatch and only the pin does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4050
Fixes #4192
Two commits, deliberately in this order: the gate first (red on the tree it lands on), the whitelist fixes second (green). Packed per #4192's triage note — the gate and the concrete gap it catches are one change.
What #4050's ruling asked for, and how each half is answered
The maintainer ruling (2026-08-10) on #4050: "Diff each action renderer's forward whitelist against the keys the runtime actually reads … A new spec action key missing from a whitelist must be a red check, not a silent drop; extraction failure is red, never a silent pass (#4690 anti-pattern)."
objectstack#6975 held this back because one half of the diff looked underivable. Its exact objection, quoted: "a
git grep 'action.'over them returns a large set ofaction.name/action.objectName/action.api/action.methodhits with no way to separate 'body-path key a renderer must forward' from 'mechanic the runner resolves itself'. Grepping produces a false-positive list, not a contract."That is true of grep, and two changes make it derivable:
RecordDetailView.tsxreadsa.locationsoff the AUTHORED action list a few hundred lines from where it readsaction.targetoff the FORWARDED def. Grep sees one set; binding-scoped AST extraction sees two, because they are different bindings in different functions.api,chain,navigate) and is nobody's to forward.Both inputs are read from their real declarations, so neither can become a stale hand copy:
authorablefrom@objectstack/spec's own zod shapes plus@object-ui/types' renderer view of an action,runtime-readfrom the three consumers' ASTs,retiredfromRETIRED_ACTION_KEYS.The surface-class split #6975 flagged is derived rather than registered:
element:buttongetsInlineActionSchema's narrower vocabulary, so it is green with no exemption entry at all. That is one fewer hand-maintained list than #6975 feared the gate would need.The gate ladder — full output on this branch
element:buttonforwarding more than it owes is not an error: the rule is one-way (owed − forwarded − excused = ∅). Forwarding a key nothing reads is harmless, and the opposite rule would fail every renderer that forwardslocationsdefensively.Red-first sequencing — the gate at commit 1, before the fixes
Checking out the first commit's renderer tree and running the gate:
It names #4192's measured
action:menugap, and then two things #4192 could not have known:action:iconandaction:groupdrop the same two keys. bug(components): action:menu's execute payload is narrower than action:button's — an overflow action loses its dialog title, description, Undo and record-id field #4192 said outright that these two "compose their own payloads too and are not measured here; a fix should check all four rather than just these two". The gate measured them.resultDialog— the one-shot reveal spec (a fresh 2FA code, a newly minted OAuth secret). Its loss is objectui#3646, fixed once onaction:buttonand still live on two of the four declared surfaces. Nobody was looking for it; the gate returned it.Per-key verdict for #4192's five probed keys
#4192 measured
PROBE-MISSING-IN-MENU: ["actionParams","description","label","recordIdField","undoable"]. Each key, judged on the runner/handler consumption rather than on list symmetry:action:menulabeluseConsoleActionRuntime.tsx:205— `title: action?.labeldescriptionuseConsoleActionRuntime.tsx:206—description: actionDescription(objForI18n, action?.name, action?.description).actionParamsactionParamsis not authorable;paramsis. The menu passesparams: action.params, and the runner accepts an arrayparamsas the collection definition (ActionRunner.ts:816), which is exactly why #4192 was a wrong TITLE and not a missing dialog. Pinned by a test so it stays latent.undoableuseConsoleActionRuntime.tsx:398—action.undoable && obj && recId && rowRecord && ….recordIdField:377—rowRecord?.[action.recordIdField or 'id'].The Undo half of #4192 is re-scoped, with evidence
#4192 states: "Clicking the same action from the 'More' menu silently loses Undo." Measured, that is not what happens, and the fix is therefore not to bring the menu's list up to the button's.
undoableandrecordIdFieldare both read only under arowRecordguard, androwRecordisparams._rowRecord(useConsoleActionRuntime.tsx:374). The only writers of that key in the repository are the four spread-based hosts:Every one of them composes its own payload and calls
executedirectly — none dispatches through these five renderers.DeclaredActionsBar.tsx:100says so in prose: it "injects the record underparams._rowRecord— whichaction:buttondoes NOT do". So on this pathaction:buttonforwardsundoableandrecordIdFieldinertly: the menu was never losing an Undo the button had. Adding the two keys would have shipped a second inert copy and made the whitelists look symmetrical while changing nothing a user can reach.That verdict is not a comment — it is a JUSTIFIED entry with this evidence attached, and the gate fails if it ever stops being true (see the ratchet below).
The two registries, and why they cannot rot
#6975's remaining objection was that a mechanical diff "needs a hand-maintained justified-omission registry beside it — which is itself the drift-prone list this whole thread is about, one level up". Both tables are ratcheted, which is the answer:
rowRecordguard, or consumed by the renderer itself (disabledis evaluated by each renderer to grey its own control;onClickis invoked byaction:menudirectly ataction-menu.tsx:212).objectName, and three of them droponClick— held as KNOWN_GAPS by check:action-forward-parity #4202. Same shrink-only governance ascheck-spec-symbol-derivation.mjs's DEBT map.An entry that excuses nothing — because the key became forwarded, or stopped being owed, or the surface is gone — is itself a failure. Neither table can outlive the code it excuses.
Reverse verification — five probes, direction predicted before running
target: schema.targetfromaction:button)targetaction:button … does not forward 1 key the runtime reads: `target`.bodyCodecin the renderer view + a read inActionRunner) — the seventh drift, simulatedaction:button … does not forward 1 key the runtime reads: `bodyCodec`.action:menu:onClickfrom JUSTIFIED)action:menu … does not forward 1 key the runtime reads: `onClick`.execute({…})call inaction:menuexpected exactly one `execute({…})` call … found 0.exit=1OPAQUE_SPREADSentry foraction:iconcannot resolve the spread `...localContext` … treating it as empty would be the silent pass the ruling forbids.Reverse verification of the behavioural pin, run against the unfixed
action:menu:The two that stayed green are the controls: the inline
action:buttonpath (never broken) and the array-paramsshape pin.Non-vacuity, held permanently rather than demonstrated once
The probes above prove the gate works today.
scripts/__tests__/check-action-forward-parity.test.ts(34 tests) keeps it true: the gate was refactored into the repo's established shape — exported pure functions over an injectable root, CLI behindinvokedDirectly, exactly likecheck-control-bytes.mjsandcheck-changeset-presence.mjs— so every red it can produce is driven from a synthetic repo rather than from whatevermainhappens to contain. Extraction failures throw rather than returning a clean verdict, and each throw is asserted:OPAQUE_SPREADSentry declares the one whose source provably cannot carry action keysexecutecalls is red tooRETIRED_ACTION_KEYS: each redPlus the real-repository non-vacuity floor (every surface owes more than five keys, every consumer read set is non-empty, every registry entry carries a reason and an issue number).
Verification
The 48 files include every
action/__tests__suite, so #4162'sautoTriggerpins and #4166's arming pins stay green, and everyscripts/__tests__pin, so theci.ymlstep and its row incontent/docs/guide/ci-cd-pipeline.mdagree in both directions.Scope
ObjectGrid.tsx,DeclaredActionsBarandRelatedRecordActionsBridgeare AST-read subjects only — not edited. The gate is wired inci.yml'stype-checkjob besidecheck:spec-symbols(objectui runs its family gates there, not in the Lint job); it needs the install because it resolves@objectstack/spec, but nothing built.Changeset:
@object-ui/componentspatch. The scripts side owes none.Generated by Claude Code