Skip to content

fix(app-shell,core): the record page's Undo captures only what the record carries, through core's one capture rule (objectui#11082) - #11121

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-11082-detail-undo-capture
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-11082-detail-undo-capture

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11082
Clause-②: yes — captureUpdateUndoData becomes a named export of @object-ui/core, a new published symbol (minor on @object-ui/core). Its answers do not change. No declared type or accepted key moves.

What was wrong

RecordDetailView's own api handler built the Undo snapshot of an undoable single-record update as pageRecord[k] ?? null. That is the shape objectui#10404 removed from ActionRunner and useConsoleActionRuntime. The page record is read by findOne with no column list, but ObjectStack's FieldMasker deletes every field the reader may not read. So when an undoable record-page action writes a field the reader cannot read, the field is absent from pageRecord, null is captured, and pressing Undo writes null over the stored value.

What changed

  • @object-ui/core: captureUpdateUndoData(writtenFields, rowRecord) in ActionRunner.ts is now exported by name. The package barrel already re-exports ActionRunner.ts through actions/index.ts, so no barrel line changed. The function body is unchanged. rowCarries stays private.
  • RecordDetailView (api handler, generic default branch): the snapshot is captureUpdateUndoData(Object.keys(params), pageRecord). When it answers undefined, no undoable op is returned, so the runner pushes nothing and the success toast carries no Undo button. The write still happens. A console.warn naming the missing fields fires after the successful write, the same diagnostic the runner and the console runtime give. A carried value, null included, is captured as before.
  • useConsoleActionRuntime (api handler, generic branch): the inline copy of the rule is replaced by a call to captureUpdateUndoData, so one rule remains. Its warning and its missing list are unchanged. The list is derived per field through the same helper (captureUpdateUndoData([k], row) === undefined), so there is no second copy of the "carries" predicate.
  • packages/core/README.md: a short section documents the new export (AGENTS.md Add automated testing infrastructure and CI/CD workflows #2). The README documents exported helpers section by section, and check:readme-exports now judges this import.
  • Changeset .changeset/11082-detail-undo-capture.md: '@object-ui/core': minor, '@object-ui/app-shell': patch.

The description: action.label || `Undo ${targetObject}` fallback that objectui#11080 owns is kept byte-for-byte. It moved two spaces right into the new if (undoData) block, so objectui#11080 will rebase onto the re-indented line.

Pins

  • packages/app-shell/src/views/RecordDetailView.undoCapture-11082.test.tsx runs the real view. A probe inside the view's own ActionProvider hands back execute, so the action runs through the real runner, the view's api handler, and the view's toast handler. The Undo button is the success toast's action; pressing it runs the real useGlobalUndo.
    • A written field the page record does not carry (the FLS-masked case) gives no Undo button, no op on the undo stack, and a warning with missing: ['status']. The write still happens.
    • A carried field next to an uncarried one gives no Undo at all (no partial Undo).
    • A carried null is restored as null.
    • A carried value is restored as that value.
  • packages/core/src/actions/__tests__/captureUpdateUndoData.export-11082.test.ts: the package entry exports the function, it is the runner module's own function, and it answers as before. That covers carried values, carried null, an absent field, a partial set, an own key holding undefined, and an inherited key.

Ablation (fix committed first; mutation and restore through objectstack scripts/ablation-replace.mjs in wrap mode): the anchor const undoData = captureUpdateUndoData(written, record); in RecordDetailView.tsx was replaced by the pre-fix record[k] ?? null loop. Result: 1 hit, blob 33aab695363d changed to 131c67079e99. The pin then read Tests 2 failed | 2 passed (4): the uncarried case and the partial case failed with AssertionError: expected { …(8) } to be undefined. The carried-null and carried-value cases stayed green, as expected, since ?? null also answers correctly for them. Restore: blob equals HEAD (33aab695363d) and git diff HEAD is empty. No dist/ is involved, because the test imports the view by relative path and vitest aliases @object-ui/core to packages/core/src.

Local verification (at 251bd2a05)

  • pnpm exec vitest run --maxWorkers=2 --reporter=verbose packages/core/src/actions/ packages/app-shell/src/views/RecordDetailView.undoCapture-11082.test.tsx: Test Files 31 passed (31) / Tests 479 passed (479). All 5 core pins and all 4 app-shell pins are listed.
  • Every test file that imports or mocks useConsoleActionRuntime or RecordDetailView (73 files, found by git grep over import, import() and vi.mock specifiers; the 10404, 11056 and 11082 pins are included): Test Files 73 passed (73) / Tests 591 passed (591).
  • Dependency closure built first: turbo run build --filter=@object-ui/app-shell^... (28/28 tasks), then the check:doc-snippets build scope (35/35). pnpm --filter @object-ui/app-shell type-check: exit 0. pnpm --filter @object-ui/core type-check: exit 0. Both run tsc --noEmit && tsc -p tsconfig.test.json, so the tests are compiled too.
  • pnpm exec eslint on the 5 changed TS files: exit 0, 0 errors. The diff adds no any and removes one as any.
  • Exit 0 for each of: check-changeset-presence, check-changeset-no-major, check-control-bytes, check:new-line-citations (0 new citation(s)), check:changeset-claims, check:readme-exports (552 real, 0 wrong-path, 0 fabricated, with the new captureUpdateUndoData row judged real), check:doc-snippets (682 of 682 block(s) judged, 0 failed), check:doc-fences, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check-type-check-coverage, check:self-import, check:pending-changeset-literals, check:test-path-roots, check:unreferenced-sources, check:handler-key-reads.
  • check-governed-queue-guard --test on the 7 paths: NOT GOVERNED.

Declared narrowing: the whole app-shell suite was not run locally. The run covers the direct importers of both changed modules, not modules that reach them through a barrel. Repo-wide pnpm lint and pnpm test were not run either; those belong to CI.

Acceptance notes

  • No content/docs page documents the Undo capture rule. content/docs/core/enhanced-actions.mdx only names the Undo button's label. No docs page was added, since that is outside this card's file surface. The rule is documented in the core README section above.
  • Observation, not filed (read-only inference, not reproduced; carrier: none): the page record is read with $expand on declared reference fields, and objectql writes the expanded record back under the same key. So an undoable record-page action that writes a lookup field would capture the expanded object, not the id, as the prior value. Whether the update route accepts that shape was not measured.

Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec (PM seat domain:ui #2, branch claude/issue-11082-detail-undo-capture).


Generated by Claude Code

…cord carries (objectui#11082)

RecordDetailView's own `api` handler captured its Undo snapshot as
`pageRecord[k] ?? null`, so a written field the page record does not carry
(the server strips fields the reader may not read) was captured as `null`
and Undo wrote `null` over the stored value.

`captureUpdateUndoData` is now a named export of `@object-ui/core`, and both
app-shell `api` handlers (RecordDetailView and useConsoleActionRuntime) call
it instead of restating the rule. When any written field is not carried the
record page offers no Undo and warns with the missing fields.

Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation package: core tests package: app-shell labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 3 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/7997-detail-view-related-retired.md

  • names RecordDetailView.tsx → packages/app-shell/src/views/RecordDetailView.tsx — edited by this change

    Why it retired. @objectstack/spec declares no DetailView schema at all — every DetailView occurrence in packages/spec/src is prose about this repo's own RecordDetailView.tsx — so this array mirrored no protocol schema and drifted freely: it declared columns as TableColumn[] while the renderer it fed also accepted bare field names, { field, label } and legacy { name, label } spellings. The axis that carried the ruling was measured zero pull: no application code authored the member, both internal producers of a detail-view node (RecordDetailDrawer, renderers/record-details.tsx) synthesize it without related, and the only in-tree authorings carrying real columns were two documents — both rewritten here.

.changeset/8318-jsdoc-default-tag-corrections.md

  • names core/src/actions/ActionRunner.ts → packages/core/src/actions/ActionRunner.ts — edited by this change

    • CRUDDialogSchema.size / .closeOnOutsideClick / .closeOnEscape / .showClose — there is no register('crud-dialog' anywhere, so no node of that type ever reaches a renderer. Recorded once on the interface. Per key, the name census differs: two spellings occur nowhere outside the declaration and its zod twin, and showClose's one other occurrence (renderers/overlay/drawer.tsx:38) belongs to DrawerSchema. - ActionSchema.level — type: 'action' is not a rendered node type, and core/src/actions/ActionRunner.ts, which is what makes method / chainMode / reload / close live, does not read level. - CardSchema.variant — card IS registered, twice, and neither registration reads it: the ui route forwards the key to ui/card.tsx, which spreads onto a div and mentions variant nowhere, and the page route forwards only its designer props. - PageNodeSchema.isDefault — page IS registered, and PageRenderer neither reads the key nor forwards it: the wrapper element gets toDomProps(props), an allow-list that does not carry it.

.changeset/action-keys-warning-text-5642.md

  • names ActionRunner.ts → packages/core/src/actions/ActionRunner.ts — edited by this change

    Both halves of the message's tail had outlived the change they described. It told the author the key was warned about rather than rejected because ActionDef "still carries [key: string]: any" — objectstack#4075 step 3 deleted that index signature, and actionKeys.pin.test.ts pins the deletion in the opposite direction ({ ActionDef: false, ActionContext: true }), while actionDef-closed-surface.test.ts pins that tsc now rejects exactly such a key at the construction site. And it prescribed promoting the key to an explicit field on ActionDef "(packages/core/src/actions/actionKeys.ts)" — that file holds the INVENTORY (ACTION_DEF_KEYS); the interface is in ActionRunner.ts. The wrong pointer had teeth: an author who followed it edited the inventory alone, which is precisely the half-change the pin test reddens on, since it re-derives the inventory from the interface's AST.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with d1e683fa1 (merge-base with origin/main): 6 file(s) changed outside .changeset/, read against 1755 pending declaration(s) that publish a body (2361 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3110.2 KB 3149.4 KB
Main entry chunk (gzip) 149.3 KB 350 KB
Entry file index-Buhyv-rs.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.88KB 6.25KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
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) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
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.68KB 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) 558.84KB 133.94KB
core (index.js) 9.94KB 3.94KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 227.99KB 63.22KB
fields (index.js) 261.11KB 66.33KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.35KB 9.18KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.32KB 11.09KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.58KB 4.90KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.25KB 2.17KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 52.17KB 15.06KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 138.54KB 37.02KB
plugin-designer (index.js) 215.78KB 44.42KB
plugin-detail (index.js) 234.38KB 62.21KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 169.69KB 43.37KB
plugin-gantt (index.js) 171.88KB 42.61KB
plugin-grid (index.js) 228.99KB 62.89KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 115.82KB 28.67KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 31.93KB 9.33KB
plugin-tree (index.js) 11.21KB 3.89KB
plugin-view (index.js) 90.43KB 22.76KB
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.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.55KB 39.23KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.17KB 2.73KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 22.61KB 7.40KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 3.83KB 1.49KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 3.19KB 1.62KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.30KB 6.99KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 251bd2a053f15d7fc3a84b43977026645c36d36b
Local-runs: none

Inputs: card #11082 (body; triage 5888840929; claim 5892961994; os-dev-report 5893910327), PR #11121 (body, 7-file list, net diff against main at the head), and the head's check-runs with filter=latest. Context reads were contents/PATH?ref=HEAD only.

Gate verdicts (the head's check-runs, filter=latest, read again after the last run had completed): 43 runs, 40 success, 3 skipped (dependabot and the two coverage matrix jobs the workflow skips on a PR), 0 failure, 0 in progress. Success includes Test (shard 1/8 through 8/8), Test (dist pins), Type Check, Lint, Build & E2E, Build Docs, Doc Snippet Type Check, README Export Check, Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read, Changeset Overwrite Report, Line Citation Gate, Inert vi.mock Specifier Check, Governed Surface Queue Guard, Spec Main Shape Gate, Skill Example Check and Bundle Analysis.

① Derived judgments

  1. captureUpdateUndoData becomes a named export of @object-ui/core — right. Signature unchanged: captureUpdateUndoData(writtenFields: readonly string[], rowRecord) on a string-keyed record, answering the string-keyed snapshot or undefined. The body is byte-identical (every(rowCarries), then Object.fromEntries). It reaches the package entry through the existing export * from './ActionRunner.js' in actions/index.ts and export * from './actions/index.js' in src/index.ts, so no barrel line changed. rowCarries remains a module-private function: declared without export, re-exported nowhere. The docblock's added paragraph names the two external callers and the per-field idiom captureUpdateUndoData([field], rowRecord) === undefined; both statements are true on the head. Pinned through the package entry by captureUpdateUndoData.export-11082.test.ts (identity with the runner module's own function; carried values; carried null; absent field; partial set; own key holding undefined; inherited key).

  2. RecordDetailView's api handler (generic default branch) — right, and it matches the two siblings limb by limb.

    • Written set: Object.keys(params), where params is action.params with _rowRecord deleted and bodyExtra merged last — exactly the payload the write then sends (dataSource.update(targetObject, String(targetId), params)). The runner's writtenFields (keys of patch under collected, minus _rowRecord and recordId) and the console runtime's fields (params minus _rowRecord and recordId, plus bodyExtra) are each that surface's own payload in the same way.
    • Uncarried: undoData is undefined, no undo is put on the result, ActionRunner.handlePostExecution pushes nothing to globalUndoManager and calls the toast handler with undo: undefined, and the view's toastHandler then renders toast.success with no action — no Undo button. Same outcome as the runner and the console runtime.
    • The console.warn fires only after await dataSource.update resolves, which is both siblings' "succeeded but offers no Undo" timing, with the same { action: action.name, missing } payload; the message differs only in its last sentence, which names this surface's carry rule (the reader's field-level read) instead of the list row's ($select plus FLS) — the true statement here. missing is derived per field through the helper, not through a second predicate.
    • Carried null is captured as null; a carried value as itself. redoData: { ...params } is unchanged. The description fallback that objectui#11080 owns (action.label, else the literal Undo followed by the target object's name) is byte-identical, moved two columns right.
    • The capture is computed before the write, as it was before this PR; pageRecord is React state the write does not mutate, so the order is immaterial. The notifyRecordChanged()-when-undo tail is unchanged. The diff removes one as any (on pageRecord) and adds none; undo stays any as before.
  3. useConsoleActionRuntime moved onto the helper — right, behaviour unchanged. Old limbs: missing = written.filter(k: not hasOwnProperty(rowRecord, k) or rowRecord[k] === undefined); missing.length === 0 gates; undoData = Object.fromEntries(written.map(k: [k, rowRecord[k]])); else the warn with missing. New: undoData = captureUpdateUndoData(written, rowRecord), whose rowCarries is that same hasOwnProperty and not undefined predicate and whose success answer is that same Object.fromEntries map — so undoData is defined exactly when missing was empty and holds the same entries. if (undoData) is a safe gate: a defined answer is always an object, and written is non-empty by the enclosing guard that requires at least one key in fields. The else limb's missing = written.filter(k: captureUpdateUndoData([k], rowRecord) === undefined) equals the old filter field by field. Warn text and payload, the write-before-capture order, the undo id/description/redoData: { ...fields } are all untouched. The objectui#10404 pin useConsoleActionRuntime.undoAbsentField-10404.test.tsx is unchanged and still in the tree.

  4. packages/core/README.md section — right. A ### section beside the existing per-helper sections; its three example answers ({ status: 'open' }, { status: null }, undefined) match the helper, and its carry definition and no-partial rule restate the docblock. README Export Check on the head: success.

  5. Pins in objectui#10404's shape — right. RecordDetailView.undoCapture-11082.test.tsx runs the real view, the real runner, the view's own handlers and the real useGlobalUndo: an uncarried written field gives no Undo button, nothing on the undo stack, the write still happens, and the warn carries missing: ['status']; a carried field beside an uncarried one gives no Undo at all; a carried null is restored by an Undo write of { status: null }; a carried value by { status: 'open' }. The ablation the PR body reports (the pre-fix loop turns the uncarried and partial cases red and leaves the two ?? null-correct cases green) is the pattern the rule predicts.

  6. Nothing else on the public surface moved. The ActionRunner.ts hunk is the export keyword and docblock lines only; no type, key, accepted value or other symbol changes. @object-ui/react (useGlobalUndo, UndoManager) is untouched; @object-ui/app-shell exports nothing new.

② Semver level

Clause-②: yes — confirmed; the PR body's line and the changeset's line both say so, with the reason. '@object-ui/core': minor is right for a new published symbol whose answers do not change; '@object-ui/app-shell': patch is right for a behaviour fix with no surface change; @object-ui/react correctly takes no bump (no file of it changed). No other export or type moved (① item 6). Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read and Changeset Overwrite Report: all success on the head.

③ Boundary flags

Dev deviations (os-dev-report 5893910327), each answered:

  1. README section added — accepted. The README documents exported helpers section by section, AGENTS.md Add automated testing infrastructure and CI/CD workflows #2 asks for docs with a feature, and check:readme-exports now judges this import (green on the head).
  2. console.warn in RecordDetailView — accepted. Zone 2 item 4 asks for parity with the console runtime; the runner and the runtime both give this diagnostic, in this shape, at this point (after the successful write). Not a surface change.
  3. The PR body's #2 autolink to objectui#2 — cosmetic. It is not a closing keyword (Fixes #11082 is the only one), so it changes nothing about what merges; the seat may reword it when the draft is lifted. Not a contract matter.
  4. One lock hold for the type-checks and the docs-scope build, reporting batch-last-exit — process note; each part's own exit code was recorded. No contract effect.
  5. objectui#11080's fallback line re-indented — confirmed byte-identical in the diff; the serial constraint on finding(app-shell): the console's global Ctrl+Z / Ctrl+Shift+Z toasts in AppContent read "Undo: " / "Redo: " in English, and an unlabelled action's description is English too ("Undo: Undo task") #11080 was already declared in the claim. No contract effect.

open_questions: none declared; none found.

Out-of-scope inference (carrier none; judged here, not widened into this PR):

  • Read on the head, the inference holds. RecordDetailView reads the page record with $expand on every reference-bearing field the principal may read (buildExpandFields(objectDef.fields) filtered by perms.checkField, :560–:564). objectql's expandRelatedRecords replaces the id in place under the SAME key (record[fieldName] = recordMap.get(String(val)) ?? val), and the view's own comment at :1373 already says $expand "substituted" the stored foreign key on this surface. captureUpdateUndoData answers rowRecord[field] verbatim, and useGlobalUndo.executeOp hands op.undoData to dataSource.update unchanged. So an undoable record-page action that writes a lookup / master_detail / user / tree field captures the expanded object as the prior value, and pressing Undo sends that object back as the field's new value.
  • Whether the route accepts it (objectstack record-validator): a reference slot is judged by ReferenceIdValueSchema — an id string, never an embedded record. Under the ADR-0104 strict posture (OS_DATA_VALUE_SHAPE_STRICT_ENABLED, or a deployment whose adr-0104-value-shapes gate is closed) the write is refused with invalid_value_shape: Undo fails and the user sees the error toast. Under the default lenient posture the write is admitted, tallied and logged, so the lookup slot then STORES the expanded object — the corruption os migrate value-shapes exists to find. Either way it is a real defect at a public door: any undoable type: 'api' action on the record page whose params or bodyExtra write a reference field. The list surfaces share it wherever the grid $expands the written column, since the runner and the console runtime hand their row through the same helper.
  • Pre-existing, not introduced here. The pre-fix pageRecord[k] ?? null captured the same expanded object; this PR neither adds nor removes the exposure. The seat files it.
  • Should the helper normalise? It is the one capture rule, so a fix there reaches all three surfaces at once — but it cannot do it blind: an object in a slot is an expansion only when the field is reference-bearing (isExpandableFieldType), and a json-typed field legitimately stores an object that has an id key. The follow-up should hand the helper (or a sibling the three callers run first) the object's field map and reduce an expanded reference to its stored id; not a shape heuristic, and not in this PR.

Implemented-by: claude/issue-11082-detail-undo-capture
Reviewed-by: session_011p7ikEivgXefNDaE5S5Uec

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 16:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 76e9df0 Sep 29, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11082-detail-undo-capture branch September 29, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation package: app-shell package: core tests

Projects

None yet

2 participants