fix(spec): undoable names the written set, not patch alone - #19283
Conversation
`ActionSchema.undoable`'s `.describe()` said the `patch` names exactly the
fields whose prior values are captured. An `operation: 'update'` action also
writes what its `params` collect, so on any params-carrying action that
sentence describes a strict subset of what the action writes — an Undo built
to it restores part of the change and reports the action as undone.
The executor already captures the union: `executeDeclarativeUpdateAction`
keys `undoData` off `Object.keys(data)`, `data` being the merged write bag
`{ ...patch, ...params }` from `declarativeUpdateWrite`. The doc block ~200
lines above in the same file already said "exactly the fields written". The
sentence was the outlier; this moves it.
`operation` carries exactly one member, `'update'`, so the per-operation
capture rule is a one-row rule and is stated as one.
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…ol page's copy of the same claim
`gen:schema && gen:docs` after the `undoable` describe() correction. The text
renders into three generated tables — `ui/action`, `data/object` and
`kernel/metadata-plugin` — all regenerated here.
`content/docs/protocol/objectui/actions.mdx` is hand-written and carried the
identical false claim ("the patch names exactly the fields whose prior values
are captured"). Corrected in place under the bounded same-defect exemption:
same defect class, mechanical transposition of the corrected sentence, the
file is held by no open PR (26 scanned), and the derived gate-family set is
byte-identical with and without the edit.
Changeset: patch on `@objectstack/spec` — the description ships in the
package, so a consumer reading the reference page sees a different promise.
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e15a436206f1df7d5c39cb642efcfd3a8f1add71 && git checkout e15a436206f1df7d5c39cb642efcfd3a8f1add71
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 15f92842ffca0be5d24e4778698c711310a984ee 889d9c7f22613e73d5a58d5eeacc22c9d907f4a7 && git checkout -B drift-repro 15f92842ffca0be5d24e4778698c711310a984ee && git merge --no-ff 889d9c7f22613e73d5a58d5eeacc22c9d907f4a7
node scripts/docs-audit/affected-docs.mjs --json 15f92842ffca0be5d24e4778698c711310a984ee |
…doable-capture-set
Contract reviewServed-tier: Isolated at-tier review of PR #19283 against card #19148 and its binding grading (comment 5747751639). Every reading below was re-taken in this act at 2026-09-20T10:18Z: this repo at ① Derived judgmentsScope matches the ruling. The grading settled "the sentence moves; do not narrow objectui#7551". The diff (6 files, +31/-8 off merge-base Every clause of the new sentence holds on the tree. (a) The generated tables are the describe text. The 412-character string extracted from the zod file compares byte-equal to the Stop condition re-taken; the PR's objectui reading is stale. The premise the ruling hangs on (no shipped runtime restores from the patch-only set) HOLDS, but not on the evidence the PR gives. The PR read Old-sentence survivors at head, for the seat. Homonyms confirmed. Declared deviation — the four conditions hold (review-checklist: same defect class, mechanical, no other claim, same gate family). (1) Report-side inaccuracies, none load-bearing. The dev report cites Not verified here. The raw log of the failing ② Semver levelMeasured: a prose-only change to a published package. The ③ Boundary flags
Two flags to route, neither a boundary change:
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19148
Clause-②: no
ActionSchema.undoable's published sentence said itspatchnames exactly the fields whose prior values are captured. Anoperation: 'update'action also writes whatever itsparamscollect, so on any params-carrying action that sentence describes a strict subset of what the action writes — an Undo built to it restores part of the change and reports the action as undone.Triage settled the direction at comment 5747751639 and this PR executes it verbatim:
All readings below were taken against
origin/main=1739f71879f, the base this branch is cut from, between 2026-09-20T08:45Z and 2026-09-20T09:30Z. The base had not moved from the one the dispatch order names.What changed
packages/spec/src/ui/action.zod.ts— the.describe()onundoable:FROM
TO
The
//comment above the key carried the same claim ("itspatchnames exactly the fields written") and is corrected with it, now naming the executor symbol that settles the set.Prose only. No schema change, no refine, no key added or removed; the same author input parses identically before and after. That is the whole basis for
Clause-②: no, and it is what the enqueue gate will read off the diff.Premise reading 1 — does a shipped runtime restore from the DECLARED set?
No. Every reader measured captures the WRITTEN set. This is the stop condition the order named, and it does not fire.
Server,
packages/runtime/src/action-execution.ts, contract point 5:declarativeUpdateWritein the same file returns{ ...base, ...params }withbasethe staticpatch— soObject.keys(data)IS the union. TheDeclarativeUpdateUndo.undoDatadocblock next to it already reads "The prior value of EXACTLY the fields written".Console,
../objectuiatdda8f3815df, both readers key off the bag they actually send:packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:487—for (const k of Object.keys(fields)) undoData[k] = rowRecord[k] ?? null;,fieldsbeing the params bag withbodyExtramerged in, i.e. the same bag handed todataSource.update.packages/app-shell/src/views/RecordDetailView.tsx:837—for (const k of Object.keys(params)),paramsagain being the bag handed todataSource.update.⇒ correcting the prose closes the divergence rather than moving it. No behaviour change is proposed or needed.
Premise reading 2 — who else reads
undoable?Instrument
git grep -n -w 'undoable', exit code captured before any pipe. Firing control:ActionSchemain the same file, 21 hits, exit 0. Dark control:undoableZZZNOSUCH, 0 hits, exit 1. 228 hits over 43 files in this repo; 128 hits over 27 files in../objectui.Readers of
ui/Action:undoablewhose behaviour depends on the flag:packages/runtime/src/action-execution.ts:1991{ ...patch, ...params }useConsoleActionRuntime.tsx:487dataSource.updateRecordDetailView.tsx:837dataSource.updateaction-button.tsx:223ActionDefaultInspector.tsxReaders that restate the sentence rather than act on it — all corrected or regenerated here: the
.describe()itself, the three generated reference tables it renders into (ui/action,data/object,kernel/metadata-plugin), and the hand-written protocol page.One reader consults the liveness ledger and not the sentence:
packages/lint/src/lint-liveness-properties.test.ts:109asserts the lint stays silent onaction.undoable. Unaffected — the ledger row is untouched.The three other
undoablesites — both homonyms, and how that was decidedThe order named two files it had not classified. Both are homonyms, on three mechanical legs each rather than on how the word reads.
packages/spec/src/api/export.zod.ts:478and:523(ImportJobProgress/ImportJobResults/ImportJobSummary) — homonym:packages/spec/authorable-surface.base.jsonlistsapi/ImportJobProgress:undoableand siblings; the UI flag isui/Action:undoable. Different schema, different namespace.z.boolean()the SERVER computes and the client reads —packages/rest/src/rest-server.ts:846,undoable: importJobUndoable(row).ui/Action:undoableis an optional flag the AUTHOR writes and the runtime reads.packages/spec/src/system/migration.zod.ts:301— homonym, and not a schema member at all: the single hit is the English adjective inside a prose docblock ("Every one of those is undoable: a rejected write is retried, a tombstone is lifted on re-attach").git grep -n -w 'undoable'on that file returns exactly one line and it is a comment.Two further hits the same grep turned up are the same import-job homonym reaching its consumers (
packages/client/src/index.ts:6989,packages/rest/src/rest-server.ts:846and:875) and one is the adjective again (scripts/pm/check-clause2-carriers.mjs:8263, inside the word "un-undoable")."For each
operation" — the census that makes it a one-row ruleoperationisz.enum(['update']). It carries exactly one member;'delete'and'custom'are refused at parse time with their own reason. So the per-operation capture rule triage asked for is a one-row rule, and the sentence states it as one rather than inventing a table with a single row.The sentence also covers the case the enum does not: an action that declares no
operationat all. Nothing refusesundoable: truethere — the refine chain inaction.zod.tssays nothing about the pair — and the server builds noundofor it, because theundoenvelope is built only insideexecuteDeclarativeUpdateAction. That case was previously undescribed; it is now named as un-anchored rather than left to be inferred.Verification
Gates derived from this tree, not from a list:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, exit codes captured before any pipe into a TSV, reconciled with--ran.pnpm --filter @objectstack/spec run check:docs— exit 0 (the gate that owns the three regenerated tables).pnpm --filter @objectstack/spec run check:generated— exit 0, all 16 artifacts current.pnpm --filter @objectstack/spec test— exit 0, 500 files / 14643 tests.pnpm --filter @objectstack/spec run typecheck— exit 0 (tsc, scripts, test layer).pnpm lint— the repo-wideeslint . --no-inline-config, exit 0 at76eb07aeb63. Run whole rather than narrowed, so no narrowing needs proving.pnpm check:nul-bytes— exit 0; plus a hand sweep of the edited files for non-NUL control bytes, no hits.NOT MEASURED (1):
pnpm check:dual-build-cjs-loads, recorded exit 3 —PREREQUISITE NOT MET, it reads built output for 83 packages and a repo-widepnpm builddoes not fit the container's foreground limit. Exit 3 is that gate's own "nothing was measured" code, neither a pass nor a failure. It readsdist/loadability and this diff changes no export, entry or build config. CI runs it.No new test is owed: a prose claim is not a behaviour, and the text is already pinned mechanically —
check:docsholds the three generated tables byte-equal to what the.describe()produces, so a future edit to the sentence cannot land without moving them.One file outside the claim's declared surface
The claim declares
packages/spec/src/ui/.content/docs/protocol/objectui/actions.mdx:54is hand-written and carried the identical false claim — "undoablehas its anchor here: the patch names exactly the fields whose prior values are captured" — and is corrected in the same edit under the bounded same-defect exemption, with all four conditions measured:packages/spec/src/ui/action.zod.tsand resolves the order's residual: spec(ui): BulkActionParamSchema is strict and declares dependsOn #19090 lands inpackages/spec/src/ui/bulk-action.zod.ts, notaction.zod.ts, so this PR is not second on it.The reviewing seat may want to amend the claim's file surface to match.
Acceptance notes
Out of scope for this PR, filed nowhere, each with the PR or reader that will reach it:
packages/runtime/src/action-execution.ts:1992— the comment on contract point 5 reads "EXACTLY the fields written — the patch names them". The code beside it keys off the merged bag and is correct; the trailing clause is the same conflation this card corrects, one file over. Not a defect (no behaviour depends on it) and outside this claim's surface. Successor: the next PR touching contract point 5.packages/runtime/src/action-declarative-update.test.ts:509— the point 5 block pins the patch-only capture, the absent-field null and the no-undoable-no-undozero, but no case declaresundoable: trueand params together, so nothing would fail if the executor ever narrowed to patch-only. The behaviour is correct today; the pin that would hold it is absent. Successor: the same PR as above, or a runtime-lane card if the reviewing seat would rather route it.packages/spec/src/ui/action.zod.ts:1109— the EXECUTOR CONTRACT docblock still says "both keys areplannedin the liveness ledger until they land".packages/spec/liveness/action.jsonflippedoperationandpatchtoliveon 2026-09-08. A different defect class from this card's, so deliberately not swept in. Successor: the next PR touching that docblock.Generated by Claude Code