Skip to content

fix(core,app-shell): project every declared recordIdField, and refuse an action that names no record - #4670

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-8018-recordidfield-select
Aug 15, 2026
Merged

fix(core,app-shell): project every declared recordIdField, and refuse an action that names no record#4670
yinlianghui merged 2 commits into
mainfrom
claude/issue-8018-recordidfield-select

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of objectstack-ai/objectstack#8018

The objectui general half of the card. The silent-success half (revoke-session
answering { status: true } while matching nothing) and the sys_session.revoke_session
declaration are objectstack-side and are not addressed here, so the card stays open.

The defect, re-derived against main

An api action declaring recordIdParam identifies the record it acts on by a row
field — recordIdField, default id. Two independent things were wrong:

  1. The grid never projected it.$select is built from the listView columns, id
    and the predicate refs. recordIdField was harvested by nothing, so an action keyed
    on any other field asked the server for everything except the key naming its own
    record.
  2. The runtime dropped it silently. The seeding read was
    if (rowValue != null) body[param] = rowValue; with an empty else — the request
    went out anyway, minus the parameter. A backend that reads a missing selector as
    "match nothing" then answers success for having changed nothing.

Together: a record-scoped mutation that reports success and does nothing.

Anchors re-derived (the card's had drifted): the projection builder is
ObjectGrid.tsx:877-922, not :677-722; predicate-fields.ts:141-170 was accurate.
The card missed a second projection builder — plugin-list/src/ListView.tsx:1503-1517
feeds the same helper, so a fix landing only in ObjectGrid would have left the class
open in the other half of the repo.

What changed

Projection (@object-ui/core).listViewPredicates now also harvests
recordIdField from rowActionDefs, bulkActionDefs and the object's actions,
spelled as a synthetic record.NAME so the one existing harvester handles it — the
idiom the function already uses for conditional formatting's native
{ field, operator, value } shape. Both projection builders read that function, so
ObjectGrid and ListView gain the key with no call-site change; that is what makes
this a class fix rather than a per-surface one. Existing guards still apply: a name the
object does not declare is dropped by isProjectableField, and a new identifier gate
drops a non-identifier declaration — without it the harvester's regex would read a
prefix of a malformed name (record.not a field yields not) and contribute a
plausible wrong field instead of nothing.

Loud failure (@object-ui/core + @object-ui/app-shell). New
resolveRecordIdParamSeed is the one definition of "can this row identify the record?".
useConsoleActionRuntime's api handler now refuses the dispatch —
{ success: false, error }, before the request — when the row lacks the key or
holds null for it. The two refusals are worded differently because they point at
different repairs: an absent key is a projection or read-visibility problem, a null
value is a data one. Falsy real values (0, '', false) are values and still
dispatch.

The card allowed either half; both are here deliberately, because the projection half
alone closes only the common route. A row can still lack the key for reasons projection
cannot fix — a server-side read mask that strips the field regardless of $select, a
partial payload handed down from a host, a field the principal cannot read.

Every recordIdField declaration, enumerated

Grepped across both repos' metadata surfaces (.ts/.tsx/.json/.yml/.yaml/.js/.mjs,
excluding node_modules and dist).

objectstack — shipped metadata: exactly one

DeclarationValueIn a listView column?
platform-objects/src/identity/sys-session.object.ts:67sys_session.revoke_sessiontokenNo (mine shows ip_address, active_organization_id, created_at, expires_at)

objectui — shipped metadata: none. Every hit is machinery or fixture: the spec key
itself (ui/Action:recordIdField, action.zod.ts:1212, default id), ActionRunner
/ actionKeys / serverActionHandler plumbing, the forward-parity gate, and test
fixtures (recordIdField: 'code' in useConsoleActionRuntime.test.tsx and
serverActionHandler.test.ts). Non-shipped fixture in objectstack:
runtime/src/http-dispatcher.actions-type-dispatch.test.ts:168 (session_token).

So the class has one live instance today, and the key is authorable by any customer
app — which is what makes the value of this change prospective as much as corrective,
and why a per-action patch would have been the wrong shape.

Which instances this covers, and what awaits objectstack-ai/objectstack#7823

  • Projection half covers every recordIdField naming a field the object declares
    and the backend returns.
  • sys_session.revoke_session is NOT covered by the projection half. Measured, not
    assumed: on objectstack origin/main (8ac2323), sys_session.token already carries
    internal: true (sys-session.object.ts:219-227), and an internal field is stripped
    from result rows regardless of projection. $select will now ask for token; the row
    will still arrive without it.
  • What that instance gets from this PR is the loud failure: "Revoke Session" stops
    reporting success while revoking nothing, and refuses with a message naming the action
    and the field. That is a strictly better failure, not a fix of the instance.
  • The instance's real repair needs a mechanism this PR deliberately does not build — an
    action that names the record by id and lets the server resolve the token. That is
    objectstack-side, and Check whether sys_session.token — a live session credential — serializes over the data API (ADR-0100 channel 3 has no read protection) objectstack#7823 (the open ruling on whether
    internal: true needs a mint-path exemption) constrains it. Nothing here assumes that
    ruling either way, and no sys_session-specific mechanism was added. #7823 remains
    open; #8018 remains open.

Reproduction and verification

Reproduction is component-level, stated honestly — no browser click-path was run
(the original measurement did not run one either). The grid test drives a real
ObjectGrid against a stub dataSource and reads the $select it emits; before the fix
it was ['id', 'name'] with the declared recordIdField: 'token' absent, and the
runtime test shows the request dispatching with the parameter missing.

Reverse verification, both halves, run from the committed state, direction predicted
before running:

LegPredictedObserved
Revert predicate-fields.ts only3 grid projection cases + 2 core harvest cases red; app-shell refusal cases greenExactly that — 5 failed, 68 passed
Revert useConsoleActionRuntime.tsx only3 app-shell refusal cases red; inject/no-declaration cases green; core + grid greenExactly that — 3 failed, 61 passed

Both legs restored to a byte-clean tree (git status --porcelain empty).

The gate this change moved, and why it is a re-point rather than a deletion

check:action-forward-parity went red — legitimately, and caused by this change.
It derives its owed set from property accesses on the action binding, and moving the
recordIdField read behind resolveRecordIdParamSeed(action, rowRecord) took the key
out of the extractor's sight, which made three JUSTIFIED entries look stale.

The gate's message offers "delete the stale entries". That would have been wrong here:
the entries are still true (those renderers never write _rowRecord, so the key is
unreachable on them, not dropped), and deleting them would record "no surface owes
recordIdField" while the runtime still reads it off every forwarded def. The helper is
simply where the read lives now, so it is registered as a runtime consumer — the gate's
own "re-point this gate at it" instruction for a consumer that moved. The stale
line-number citations in the entry's reason text were corrected at the same time.

Evidence it restored the model exactly rather than papering over it: the runtime-read
union is 40 keys on main and 40 keys after, and every surface's owed count is
unchanged (24/24/24/24/12).

Verification, all at 00738ed57 (final commit)

CheckResult
pnpm exec vitest run over packages/core/, packages/plugin-grid/, packages/plugin-list/, packages/app-shell/src/hooks/, scripts/__tests__/check-action-forward-parity.test.ts222 files / 3255 tests passed
turbo run type-check — core, app-shell, plugin-grid, plugin-list (closure built via ^build)33 tasks successful; all four type-check tasks confirmed executed
turbo run lint (same four) + lint:root0 errors (warnings pre-existing repo-wide)
check:action-forward-paritygreen
check:control-bytes, check:phantom-deps, check:spec-symbols, type-check:scripts, lint:coverage, type-check:coverage, check:i18n-keys, check:skills-pathsgreen
check-changeset-presence, check-changeset-no-majorgreen

Changeset: .changeset/nervous-pugs-worry.md (minor — core, app-shell, plugin-grid,
plugin-list; never major, per the fixed-group rule).

Deliberate scope boundaries


Generated by Claude Code

@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 15, 2026 6:21am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-Bq8naT7s.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)38.46KB10.17KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.91KB108.67KB
core (index.js)3.79KB1.52KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)163.56KB44.83KB
fields (index.js)230.37KB57.17KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.84KB1.45KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.86KB12.91KB
plugin-charts (index.js)62.10KB17.67KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)121.84KB31.74KB
plugin-designer (index.js)212.58KB42.83KB
plugin-detail (index.js)239.93KB60.01KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.72KB27.70KB
plugin-gantt (index.js)164.30KB40.02KB
plugin-grid (index.js)190.02KB50.48KB
plugin-kanban (index.js)52.74KB14.53KB
plugin-list (index.js)111.82KB27.23KB
plugin-map (index.js)18.16KB5.81KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.38KB11.09KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)27.64KB9.44KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.26KB0.67KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yinlianghui@claude