Repository navigation
feat(components): action:button delivers undoable where a record is in scope, and publishes it (objectui#11168) - #11954
Conversation
…n scope as its Undo baseline, and publishes undoable Ruling B on objectui#11754 (record 6030342264): where the block runs inside a record context it passes that record as the Undo baseline the runner's `operation: 'update'` path already reads (`params._rowRecord`, the spelling the record page's header, the declared-actions bar, the related-record bridge and the grid's rows use). The record in scope is the row the host binds through `data`, else the record page's RecordContext record. It is attached only for an `undoable` `operation: 'update'` that writes that record, so a non-undoable action, a non-update action and an update addressed to another record dispatch exactly as before. `undoable` is published on `action:button` with a description that states the one limit (no record in scope, no Undo), and the parity guard's last objectui#11168 entry is struck: unpublishedKeys cap and the owner count go to 0. Prose the change made false is corrected: the UIActionSchema.undoable doc comment, the forward-parity gate's JUSTIFIED reasoning and the forward-parity test header. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude <noreply@anthropic.com>
The layout guide's action:button paragraph says an `undoable` update offers Undo from the record in scope, and when it does not. The changeset declares the components minor (a published input, a behaviour change) and the types doc comment. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude <noreply@anthropic.com>
❌ Console Performance Budget
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. Which half objected:
📦 Bundle Size Report
Size Limits
|
|
Generated by Claude Code |
Contract reviewServed-tier: Reviewed by an isolated subagent of the ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged:
② Semver level
③ Boundary flagsFrom the dev report
Check-runs on Implemented-by: VERDICT: PASS Generated by Claude Code |
✅ Console Performance Budget
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
Size Limits
|
Contract reviewServed-tier: Reviewed by an isolated subagent of the ① Derived judgments
② Semver level
③ Boundary flags
Check-runs on Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #11168
Clause-②: yes — it widens, and narrows nothing. Widening:
undoablebecomes a published input ofaction:button(the page validator stops reporting it as an unknown prop), and anundoableoperation: updateof the record in scope now offers Undo where it offered none. Nothing that was accepted before is refused.This is the last slice of objectui#11168:
action:button.undoable, ruling B on objectui#11754 (record6030342264, pointer6030356700on the card). With it the card's parity-ledger entries are all struck and every cap is 0.The ruling, as executed
When the block runs inside a record context, it now hands the runner that record as the Undo baseline the runner's
operation: 'update'path already reads (params._rowRecord, read byActionRunner.executeUpdateOperationthroughcaptureUpdateUndoData). The key is published onaction:button, and its description states the one limit: a button with no record in scope offers no Undo, because there is no row to restore. The runner is untouched.The record in scope is the row the host binds to the node through
data(a table's row,DetailView's header actions, anaction:barmember), else the record page's own record fromRecordContext. Both carriers are needed. Measured below: an authored record page renders the node throughSchemaRendererwith nodata, and its record lives only inRecordContext.When it is attached. All three must hold; otherwise the dispatched def is exactly what it was:
undoableandoperation: 'update'(the path the ruling names). Anapiaction is not touched, because the console'sapihandler reads the stash for more than Undo: it fills{field}URL tokens and seedsrecordIdParam.recordId, else the record'srecordIdField,idby default) must equal the record'sid, because the runner keys the Undo byrecordId ?? record.id. Otherwise a button writing another record would get the scoped record's values as its Undo, and restoring the wrong values is worse than no Undo.Census first (the ruling's "unmeasured gap", H3)
Stored pages that declare
undoableonaction:button: 0.The instrument is
git grepread at a named ref, so the file list and the content come from the same source. It counts files that nameaction:button, then the subset that also namesundoable, and inspects each hit. Lit controls run on the same population:actionTypeandconfirmTexton objectui, and on objectstack, which stores noaction:buttonnode at all,page:header,successMessageandoperation: 'update'.action:buttonundoableexamples/apps/content/+ fixtures19a7348(this branch's base)actionType6 files,confirmText3examples/packages/apps/f4bed58(remotemain)page:header9,successMessage6,operation: 'update'33ae5966(the shared checkout)undoableanywhere in the objectstack trees: 0. Reach: tracked files in those trees at those refs. It does not see pages stored in deployed databases, the cloud repo, or other objectstack packages. For context only, outside the ruling's trees, objectstackmainnamesundoablein spec sources, platform-object metadata-form translations, docs and one skill rule, and none of those is anaction:buttonpage node.Measured before editing (H1, H2), predictions written first
Throwaway probe, deleted before the first commit. It used the real
SchemaRenderer, the realActionRunner(ActionProvider) and the realcreateServerActionHandleras thescriptdispatch, with record{ id: 't1', status: 'open' }and anundoableupdate writingstatus: 'done'. Every prediction held:RecordContextProvider, nodata)undo; body{"params":{"status":"done"}}data_rowRecordhand-stashedundoset;undoData {status:'open'},recordId t1visible: record.status == 'open'data)H1 held. The runner reads the baseline from
params._rowRecordand offers Undo only whenaction.undoableandrowRecordare set and the written-field list is non-empty, androwCarriesneeds every written field on the record. The block forwardedundoableand wrote no stash. The existing writers' spelling (params: { ...values, _rowRecord: record }, as inpage:headerandDeclaredActionsBar) is the one reused.H2 held as stated, with one refinement. On a record page, the record the block can see is
RecordContext's, not adataprop, so the block reads both.Pins:
action-button-undoable-11168.test.tsx(12 rows, real pieces end to end)t1, and the stash is stripped from the POST);data;action:barmember on a record page;recordIdnaming another record gets no Undo and no stash (lit control:${record.id}does get Undo);undoableupdate carries the record:undoableupdate carries none (lit control: the same node madeundoable);undoablenon-update carries none.unknown-prop(lit control: an unpublished key still is).undoablealso joinsaction:button'sDECLAREDrow in the slice-1 pin file.Ablations ran on committed HEAD through objectstack's
scripts/ablation-replace.mjs(the anchor must hit, and the blob is verified). Each was wrapped by a trap that restores fromHEADon the absolute path. Predictions were written before the run. The subject is imported by relative path and source alias, so nodistsits between the mutation and the assertion.if (!schema.undoable …) return values;→return values;354e1a84dc5b→1b0e1b3be4c4git diff HEADemptye1bd2de0c963undoable_ablatedaction:button publishes every top-level key …and… declares no top-level input the spec does not accept; the arm judge stayed greenLedger (
registry-inputs-spec-parity.test.ts)action:button.undoableowed entry is struck.OBJECTUI_11111_LEDGER_CAPS.unpublishedKeysgoes 1 → 0, and the per-owner countobjectui#11168goes 1 → 0. All five caps and all five owners now read 0.OWED_TO,owedEntries) left with the last entry. The console project'snoUnusedLocalswould refuse them unused.OWED TOprefix against caps of 0, so anything booked again goes red.OBJECTUI_11111_*scaffold is not done here (see Acceptance notes).File surface, and the amendment it needed
As claimed:
action-button.tsxand its tests;.changeset/11168-button-undoable.md: componentsminor, typespatch);content/docs/guide/layout.mdthat lists whataction:buttonforwards.Amended (the seat may record it): three texts this change made false are corrected, and nothing else in them moves.
packages/types/src/ui-action.ts, theUIActionSchema.undoabledoc comment. It ships in the published.d.tsand saidaction:buttonnever seeds the row guard. Comment only, no type change.scripts/check-action-forward-parity.mjs, the JUSTIFIED reasoning. It said_rowRecordis written only by the spread-based hosts. Every verdict still holds:action:button:recordIdParamstays unreachable, because the update path dispatches toscript, never to theapihandler. Its suite ran, below.action-forward-parity.test.tsx's header, the same sentence.Verification (final HEAD
9993fb6e)Tests, from the worktree root under objectstack's
os-verify-lock.sh, each log's first line9993fb6e:packages/components/andpackages/types/suites, plus every outside reader of a touched file. The readers areuseConsoleActionRuntime.paramDialogTitle,one-authority-per-exported-name-6273, bothcheck-action-forward-parityscript suites,check-i18n-en-drift, the two closing-keyword suites,zod-wrapper-keys.shared, the parity guard,public-block-binding-reachandpublic-contract. Result:Test Files 739 passed | 1 skipped (740)/Tests 13819 passed | 24 skipped (13843), VERDICT command-exit 0.packages/components/src/renderers/action/+ the guard + the forward-parity script suites +action-group.test.tsx):Test Files 37 passed (37)/Tests 876 passed (876).Builds (under objectstack's
os-verify-lock.sh):pnpm --filter '@object-ui/console^...' run buildVERDICT command-exit 0;@object-ui/cli,@object-ui/plugin-ai,@object-ui/consolebuild VERDICT command-exit 0.type-check, script echoed: components
tsc --noEmit && tsc -p tsconfig.test.jsonexit 0; typestsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.jsonexit 0; consoletsc --noEmit && tsc -b tsconfig.node.json --forceexit 0.Gates, exit read after a redirect:
check-changeset-presence0 ("6 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)"),check-changeset-no-major0,check:changeset-claims0,check:pending-changeset-literals0,check:new-line-citations0 ("0 new citation(s)"),check:control-bytes0,check:action-forward-parity0,check:spec-symbols0,check:component-surface-parity0 (report-only; noaction:button.undoablerow),check:sdui-registration-pins0 ("All 14 registration(s) … present"),check:skill-examples0,check:doc-snippets0 ("784 of 784 block(s) judged, 0 failed"),check:doc-examples0,check:doc-fences0,check:doc-types0,check:test-path-roots0,check:handler-key-reads0,check:doc-example-readers0,check:docs-route-closure0,check:unreferenced-sources0,check-governed-queue-guard --teston the 9 paths: NOT GOVERNED.check:skill-examplesandcheck:doc-snippets/check:doc-examplesexited 2 on their stated build prerequisite. They were re-run green after the builds above.Lint (a declared narrowing;
pnpm lintis CI's):lintiseslint .under the rooteslint.config.js.--print-configgives 116–119 rules on each touched TS file and 0 onscripts/*.mjs, which no package lints.--format json, 0 errors, 22 warnings, 0 of them on an added line (intersected withgit diff -U0).eslint.config.jshas 0parserOptions, 0projectand 0projectService(controls:rules13,typescript-eslint7), so linting is not type-aware and this diff cannot move a verdict on an untouched file.Acceptance notes (observed, not filed)
visible/disabledpredicates do not see the record page's record. Measured in the probe: a node underRecordContextProviderwithvisible: record.status == 'open'renders hidden, becauseusePredicateRecordContext(data)binds only thedataprop and the console's predicate scope carries norecord.SchemaRenderer's node-level gate bindsRecordContextand passes, then the renderer's own fail-closed re-evaluation hides the button. This PR does not change predicate binding; it readsRecordContextfor the Undo baseline only. Reach through a public door was not measured, and no stored page with such a node was found (census above). Carrier: none.DeclaredActionsBar's docblock still says the block does not inject the record "which the api handler needs". That stays true forapiactions, the subject of the sentence, so it is left as is.recordId ?? record.idwhile the route resolvesrecord[recordIdField]. This PR avoids the mismatch at the block, by attaching only when the two agree. The runner-side asymmetry predates it and also applies to list-row writers. Noted, not touched (the runner is outside the claim).OBJECTUI_11111_*ledger scaffold (owners, bookings, the cap test) stays with every figure at 0, the convention earlier landings kept so that nothing re-books silently. Retiring it is the seat's call.Session:
https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw(domain:specseat 1, mode subagent).Generated by Claude Code