Skip to content

ci(gate): diff each action renderer's forward whitelist against the keys the runtime reads (#4050, #4192) - #4207

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4050-action-whitelist-gate
Aug 10, 2026
Merged

ci(gate): diff each action renderer's forward whitelist against the keys the runtime reads (#4050, #4192)#4207
yinlianghui merged 2 commits into
mainfrom
claude/issue-4050-action-whitelist-gate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

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 of action.name / action.objectName / action.api / action.method hits 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:

  1. Read the consumers with the compiler API, scoped to the binding the def arrives as. RecordDetailView.tsx reads a.locations off the AUTHORED action list a few hundred lines from where it reads action.target off the FORWARDED def. Grep sees one set; binding-scoped AST extraction sees two, because they are different bindings in different functions.
  2. Intersect that with what the surface may be AUTHORED with. A key the runtime reads but no author can write is a runner mechanic (api, chain, navigate) and is nobody's to forward.
owed(surface) = authorable(surface) ∩ runtime-read − retired

Both inputs are read from their real declarations, so neither can become a stale hand copy: authorable from @objectstack/spec's own zod shapes plus @object-ui/types' renderer view of an action, runtime-read from the three consumers' ASTs, retired from RETIRED_ACTION_KEYS.

The surface-class split #6975 flagged is derived rather than registered: element:button gets InlineActionSchema'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

$ pnpm check:action-forward-parity
✅  action forward parity: 5 surfaces checked against 41 runtime-read keys from 3 consumers; 19 justified omissions, 7 known gaps.
    action:button  owes 24, forwards 21
    action:icon    owes 24, forwards 18
    action:group   owes 24, forwards 18
    action:menu    owes 24, forwards 18
    element:button owes 11, forwards 18

element:button forwarding 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 forwards locations defensively.

Red-first sequencing — the gate at commit 1, before the fixes

Checking out the first commit's renderer tree and running the gate:

❌  an action renderer drops a key the runtime reads:

    • action:icon (packages/components/src/renderers/action/action-icon.tsx) does not forward 3 keys the runtime reads: `description`, `label`, `resultDialog`.
    • action:group (packages/components/src/renderers/action/action-group.tsx) does not forward 3 keys the runtime reads: `description`, `label`, `resultDialog`.
    • action:menu (packages/components/src/renderers/action/action-menu.tsx) does not forward 2 keys the runtime reads: `description`, `label`.

exit=1

It names #4192's measured action:menu gap, and then two things #4192 could not have known:

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:

Key Runtime-read Owed by action:menu Verdict Evidence
label yes yes was dropped, now forwarded useConsoleActionRuntime.tsx:205 — `title: action?.label
description yes yes was dropped, now forwarded useConsoleActionRuntime.tsx:206description: actionDescription(objForI18n, action?.name, action?.description).
actionParams yes no not owed — latent divergence, pinned actionParams is not authorable; params is. The menu passes params: action.params, and the runner accepts an array params as 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.
undoable yes yes justified omission, not a defect Read only at useConsoleActionRuntime.tsx:398action.undoable && obj && recId && rowRecord && ….
recordIdField yes yes justified omission, not a defect Read only at :377rowRecord?.[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.

undoable and recordIdField are both read only under a rowRecord guard, and rowRecord is params._rowRecord (useConsoleActionRuntime.tsx:374). The only writers of that key in the repository are the four spread-based hosts:

packages/app-shell/src/views/DeclaredActionsBar.tsx:215          params: { _rowRecord: record },
packages/app-shell/src/views/RelatedRecordActionsBridge.tsx:164  params: { _rowRecord: record },
packages/plugin-grid/src/ObjectGrid.tsx:1721                     dispatch.params = { _rowRecord: r };
packages/plugin-grid/src/ObjectGrid.tsx:2034                     params: { ...params, _rowRecord: row },
packages/components/src/renderers/layout/containers.tsx:1287     dispatch.params = { _rowRecord: record };

Every one of them composes its own payload and calls execute directly — none dispatches through these five renderers. DeclaredActionsBar.tsx:100 says so in prose: it "injects the record under params._rowRecord — which action:button does NOT do". So on this path action:button forwards undoable and recordIdField inertly: 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:

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

Probe Predicted Measured
Remove one owed forward (target: schema.target from action:button) red naming target action:button … does not forward 1 key the runtime reads: `target`.
Plant a NEW authorable key the runtime reads (bodyCodec in the renderer view + a read in ActionRunner) — the seventh drift, simulated red on every declared surface action:button … does not forward 1 key the runtime reads: `bodyCodec`.
Delete a registry entry (action:menu:onClick from JUSTIFIED) red naming the now-unexcused key action:menu … does not forward 1 key the runtime reads: `onClick`.
Break extraction: rename the execute({…}) call in action:menu red, not "nothing owed" expected exactly one `execute({…})` call … found 0. exit=1
Break extraction: drop the OPAQUE_SPREADS entry for action:icon red, not an empty spread cannot 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 overflow path hands the dialog the action`s own label and description
× carries the label through to the runner itself, not only to the dialog
AssertionError: expected undefined to be 'Create Environment'
Tests  2 failed | 2 passed (4)

The two that stayed green are the controls: the inline action:button path (never broken) and the array-params shape 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 behind invokedDirectly, exactly like check-control-bytes.mjs and check-changeset-presence.mjs — so every red it can produce is driven from a synthetic repo rather than from whatever main happens to contain. Extraction failures throw rather than returning a clean verdict, and each throw is asserted:

  • an unresolvable spread is not read as an empty object; an OPAQUE_SPREADS entry declares the one whose source provably cannot carry action keys
  • a renamed consumer binding is red, not "the runtime reads nothing" (which would make every owed set empty and the whole gate vacuously green)
  • a moved or non-literal forward site is red, not "no payload, nothing owed"; a surface split across two execute calls is red too
  • an empty owed set is red — the #4690 shape the ruling names
  • a renderer view with no properties, a renamed view, a missing RETIRED_ACTION_KEYS: each red

Plus 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

pnpm vitest run scripts/__tests__ packages/components/src/renderers/action --maxWorkers=2
  Test Files  48 passed (48)
        Tests  1072 passed (1072)

pnpm check:action-forward-parity   ✅ (above)
pnpm type-check:scripts            OK
pnpm check:control-bytes           ✅ scanned 3897 tracked text file(s)
pnpm changeset:check               ✅ fixed group, no major
pnpm --filter '@object-ui/components^...' build   Done
pnpm --filter @object-ui/components type-check    clean
eslint (6 changed files)           0 errors, 41 warnings (pre-existing no-explicit-any)

The 48 files include every action/__tests__ suite, so #4162's autoTrigger pins and #4166's arming pins stay green, and every scripts/__tests__ pin, so the ci.yml step and its row in content/docs/guide/ci-cd-pipeline.md agree in both directions.

Scope

ObjectGrid.tsx, DeclaredActionsBar and RelatedRecordActionsBridge are AST-read subjects only — not edited. The gate is wired in ci.yml's type-check job beside check: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/components patch. The scripts side owes none.


Generated by Claude Code

claude added 2 commits August 10, 2026 23:27
…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
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 10, 2026 11:30pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.3 KB 350 KB
Entry file index-DXVY_0vO.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.88KB 3.25KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 486.47KB 107.53KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 140.66KB 36.25KB
fields (index.js) 226.96KB 56.30KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.87KB 10.80KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.52KB 17.49KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.52KB 30.68KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 162.81KB 39.67KB
plugin-grid (index.js) 188.04KB 49.91KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.04KB 26.67KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.95KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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