Repository navigation
fix(rest)!: an import undo runs under the object's declared controls for every caller but the system principal - #24431
Conversation
…for every caller but the system principal The undo door's restore and delete writes keep skipAutomations and skipStateMachine only for the system principal. A reversal a control refuses is reported per row in the undo answer and kept, with its refusal, in the job's undo log; reverted_at is stamped only when every row was reverted. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…s names no undo exception Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…ble surface Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…ing on spec Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…port-undo-controls Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 146 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 3c3042956018156df114ffc9e7efcd6e3e19d32d && git checkout 3c3042956018156df114ffc9e7efcd6e3e19d32d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db1f1fd20fba543408b209746e566b48373a4622 83425d5bc1cb39855f7f991131b7d0d8d3c60e69 && git checkout -B drift-repro db1f1fd20fba543408b209746e566b48373a4622 && git merge --no-ff 83425d5bc1cb39855f7f991131b7d0d8d3c60e69
node scripts/docs-audit/affected-docs.mjs --json db1f1fd20fba543408b209746e566b48373a4622
|
席位核对ACCEPT (lane review; ⛔ not yet queueable: a
Generated by Claude Code |
Contract reviewServed-tier: Read against card #24380 (body and every comment: triage ① Derived judgmentsEach ruling item (
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL One finding blocks: the Rendered 2026-10-19T02:54Z · read-only: the diff, the card and the head's check-runs, no local run. |
席位核对 — REWORK (patch round 1: the spec changeset's Clause-② arm, and three lines this PR made stale)
The seat's miss, named: the lane ACCEPT Owed in this round:
Acceptance notes, not changed here: ⛔ Do not patch the PR body. Give exact old/new pairs for any section the round makes stale; the seat writes them. A fresh Generated by Claude Code |
…wing; ExecutionContext names runImport's in-process caller The @objectstack/spec changeset now reads fix(spec)!:, Clause-②: yes (narrowing), one ADR-0087 marker and a BREAKING sentence saying who notices the required failures key. The skipAutomations TSDoc says the import doors admit the opt-out over HTTP from the system principal alone while an in-process caller of runImport passes its own setting. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…failures it answers Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…eps the unreverted rows with a failures list Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…scription node scripts/check-i18n-bundles.mjs --filter=platform-objects --write; the translated locales keep their values (merge mode). Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
席位核对 — amendment for head
|
Contract reviewServed-tier: Re-review after record ① Derived judgmentsCarried. The compare moves none of
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS The one blocking finding of record Rendered 2026-10-19T03:51Z · read-only: the diff, the card and the head's check-runs, no local run. |
Fixes #24380
Clause-②: yes (narrowing)
Basis for the Clause-② line (from the claim,
6257221825, which names this case): the undo door stops taking an app's declared controls off its writes for every caller but the system principal, which is a narrowing; and the undo's published answer,UndoImportJobResponseSchema, gains one key,failures, which widens the answer and, because the key is required, also narrows the schema's accept set: a response without it is refused, and code that constructs anUndoImportJobResponsemust set it (the shapeListAiConversationsResponseSchema.hasMoreset).yes (narrowing)declares both. Both changesets carry the same line, each with its one ADR-0087 marker.Executes the ruling
6257104178(batch 366 item 2, letter B, maintainer 「24380 B」). Detail is withheld under the maintainer's disclosure ruling of 2026-08-18 on #9387: this body gives code positions and the fix's behaviour only.What changes
POST /api/v1/data/import/jobs/:jobId/undo(packages/rest/src/data-action-routes.ts) reverses a finished import: it deletes the records the job created and restores the fields it updated.skipAutomationsandskipStateMachinefor the system principal only (ExecutionContext.isSystem === true, an in-process caller such as a migration job). Every other caller, every signed-in user and administrator included, undoes under the object's metadata-bound hooks, its record-change triggers and itsstate_machinerule. The predicate is the one the import doors ask before they honour a control opt-out (importControlOptOutRefusalinimport-prepare.ts, landed in741aa85da): only the system principal may take an app's declared controls off a write, and the undo door is not an exception. The write context is built in one place,importUndoWriteContext. No permission, capability or config key is read or added.failuresarray, one{ id, action, code, error }per row, whereactionisdeleteorrestoreandcodeanderrorare whatPATCH/DELETE /data/:object/:idanswer the same thrown error with (mapDataError, then the nested ADR-0112 envelope's code; a hook's refusal reads in the hook's own sentence, and a fault's driver text stays withheld).failedis the length offailures, andsuccessisfalsewhen any row is listed.reverted_atonly when every row was reverted. An undo that left rows unreverted stamps nothing and rewrites the job's undo log to exactly those rows, each refusal beside them underfailures. The job stays undoable, and undoing it again retries only those rows, never one already reverted.treat_as_historical) keepspreserveAuditon its undo for every caller. The job-row write that records the outcome stays engine-owned (isSystem: true, ADR-0103). The404,409 ALREADY_REVERTEDand422 NOT_UNDOABLEanswers are unchanged. The import doors and their opt-out gate are not touched. The undo writes go through the protocol'sdeleteData/updateData, not through@objectstack/core's import runner, which is unchanged.The cost, named: undoing an ordinary import may fire record-change automation again (a notification, an approval re-opening on a restored amount). That is the app's declared behaviour for those writes.
Spec and docs
packages/spec/src/data/hook.zod.ts: theskipAutomationsdescribe on the hook context'ssessionnames the system principal's import and undo as the skip cases and states that the import undo door is not an exception.packages/spec/src/kernel/execution-context.zod.ts: theskipAutomationsTSDoc says the data-import runner (runImport) sets it from its caller'srunAutomations: false, which the import doors admit over HTTP from the system principal alone while an in-process caller of the runner passes its own setting, and that the import undo sets it only when the system principal undoes a job; theskipStateMachineTSDoc says the same of the undo.packages/spec/src/api/export.zod.ts:UndoImportJobResponseSchemagainsfailures; thesuccessandfaileddescribes match it;revertedAtonImportJobProgressSchemaandImportJobSummarySchemasays it is set only once every row is reverted.content/docs/permissions/system-context.mdxgains row 30c for the newisSystemread (packages/rest/src/data-action-routes.ts#importUndoWriteContext);content/docs/protocol/objectql/state-machine.mdxsays the undo walks the state machine for every caller but the system principal.content/docs/references/api/export.mdx,content/docs/references/data/hook.mdx,packages/spec/authorable-surface/api.jsonand the strictness-ledger counts.packages/platform-objects/src/audit/sys-import-job.object.ts: theundo_logfield's description adds that a partial undo keeps only the unreverted rows there, with afailureslist giving each one's id, action, code and refusal. Itseni18n bundle (packages/platform-objects/src/apps/translations/en.objects.generated.ts) is regenerated withnode scripts/check-i18n-bundles.mjs --filter=platform-objects --write; the translated locales keep their values (merge mode).Changesets
.changeset/24380-import-undo-controls.md:'@objectstack/rest': minor, summaryfix(rest)!:,Clause-②: yes (narrowing), one ADR-0087 marker (not-required (no-migration-prescription)), and a**BREAKING**sentence that names the cost and the remedy (resolve what refuses the row on the app and undo again; a reversal that must skip the controls runs in-process as the system principal)..changeset/24380-undo-response-failures.md:'@objectstack/spec': minor, summaryfix(spec)!:,Clause-②: yes (narrowing), one ADR-0087 marker (not-required (no-migration-prescription): no authored key or stored shape moves), and a**BREAKING**sentence saying who notices the requiredfailures: a response parsed withUndoImportJobResponseSchemais refused without it; code constructing anUndoImportJobResponsemust set it,[]on a full revert; readers ofclient.data.undoImportJobare unchanged.failuresstays required.Tests
packages/rest/src/import-undo-controls.test.ts, over the real routes, a real ObjectQL engine, the real protocol and a SQLite:memory:driver. The fixture app declares an approval-gate hook, a closed-record freeze hook (on update and delete) and astate_machinerule with a terminal state.success: true,failures: [], the job stamped reverted).preserveAuditon a signed-in caller's undo, which carries no control skip.preserveAuditon the system principal's undo, beside its skips.Readings at head
79b4ccf16:pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/import-undo-controls.test.ts: 6 passed.pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2: 299 files passed, 6085 tests passed, 326 skipped.pnpm --filter @objectstack/rest run typecheck: exit 0;check:test-typecheckOK, and--listFilesontsconfig.test.jsonincludes the new suite.pnpm --filter @objectstack/spec run typecheck: exit 0. Spec suites over the edited schemas (src/api/export.test.ts,src/data/hook.test.ts,src/kernel/execution-context.test.ts,src/type-alias-convention.pin.test.ts): 169 passed;--project repo src/api/export-job-family-retirement.test.ts: 7 passed.eslint --no-inline-configover the six changed TypeScript files: exit 0, no output.Patch round 1, readings at head
83425d5bc:pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/rest-write-response-internal-fields.tripwire.test.ts src/import-undo-controls.test.ts: 2 files, 21 passed. The tripwire's undo row keepsno-record-echo; itswhynow says the answer carries counts and per-rowfailures(record id, action, code, refusal sentence), never a record body.pnpm --filter @objectstack/rest run typecheck: exit 0.pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2: 77 files, 1184 passed;pnpm --filter @objectstack/platform-objects run typecheck: exit 0.pnpm --filter @objectstack/spec run typecheck: exit 0;src/kernel/execution-context.test.tsandsrc/api/export.test.ts: 70 passed.eslint --no-inline-configover the four files this round changed: exit 0, no output.Ablation of the gate (one-off, not kept):
scripts/ablation-replace.mjsreplaced the system-principal conditional inimportUndoWriteContextwith unconditional skips (anchor hit 1 time, blobbf6a57a75bbfto01b7721efd21), ran the suite, and restored. Exactly the four signed-in-caller pins turned red, each on its own assertion (the approval gate did not re-open approval; the state machine refused nothing, sofailedread 0; the freeze refused nothing, so the delete and restore both went through; the historical undo carriedskipAutomations). The two system-principal pins stayed green. Restore proven: blob after restorebf6a57a75bbfequals the blob atHEAD, andgit diff HEADis empty. The rest suite resolvesdata-action-routes.tsfromsrc/, so no build sat between the mutation and the run.Gates
Local subset at head
79b4ccf16, the derivation's own list:node scripts/pm/dispatch-gates.mjs --commandsprinted 111 commands for this change set (14 paths vs merge base8baeda520, +571 / -44 lines, under the 3000-line threshold). All 111 were run one by one with each exit code captured before any pipe, and all 111 exited 0.node scripts/pm/dispatch-gates.mjs --ranover that record: "111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. Patch round 1 at head83425d5bc: the derivation printed 113 commands (17 paths, +584 / -48; the two new ones arepnpm check:i18nandpnpm check:i18n-stale-fill); all 113 were run with each exit code captured before any pipe and all 113 exited 0 (two first answered exit 3, prerequisite not met in a fresh worktree, and exited 0 once the named packages were built);--ran: "113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. Verdict lines from the families this diff is about, at the round's head:check-adr-0087-registration.mjs --base origin/main: "2 declared-breaking changeset(s), each carrying an ADR-0087 disposition" (24380-import-undo-controls.mdand24380-undo-response-failures.md, each with signalsBREAKING+bang+clause-②-narrowingandnot-required (no-migration-prescription)).check-changeset-no-major.mjs --base origin/main: "This diff introduces nomajorbump". Its level axis reads the PR body, so it was also driven offline with this body as the event payload (--event): "this PR declares clause-②yes (narrowing), and no package whose published source ... it moves is gradedpatch", exit 0.check-empty-changeset.mjs: no empty-frontmatter changeset, 2 declaring changesets added.check-system-context-census.mjs: OK, 144 elevation read sites; the page cites 138 symbols against 138 required (row 30c anchors the new read).@objectstack/spec:check:generated"All 14 generated artifacts are up to date";check:docs"227 generated files in sync";check:api-surfaceunchanged;check:authorable-surface,check:strictness-ledgergreen.check:nul-bytesOK;check:route-envelopegreen;check:cross-package-test-inputsOK.check:i18n"OK (10 package(s) — all bundles in sync, no undeclared authoring keys)";check:i18n-stale-fillOK;check:keyed-text-boundsgreen.check:dev-prereqs(the spec build was touched): exit 0.Outside the derived list and owned by CI: the type-check lanes,
Test Core,Build Core,Build Docs, the declared-wide families and the repo-widepnpm lint.Resumed work
This branch resumes a dev run stopped by a container restart before it reported (
a23786da7, five commits). All five were kept as they were, after reading each against the ruling and the claim; this run mergedorigin/main(no conflict, nothing generated touched) and added no other commit.Patch round 1 (record
6258647218, ② FAIL on the spec changeset's arm, and the seat's REWORK) added four commits:aabf9edb5(the spec changeset as a narrowing; theExecutionContextTSDoc namesrunImport's in-process caller),7c779806f(the tripwire's undo row),483069d1f(theundo_logdescription) and83425d5bc(its regeneratedenbundle).failuresstays required.Acceptance notes
packages/plugin-grid/src/ImportWizard.tsx,useImportJobUndo, read at objectui79cb8c0) awaits the undo and does not read its answer, so after a partial undo it shows no message for the rows left unreverted, and the job stays listed as undoable. A code reading, not measured; filed as #24433.GET /import/jobs/:jobIdand/resultsdo not project the storedfailures; the undo answer and the job row carry them, which meets the ruling's "job's history". The three translated locales of theundo_loghelp text keep their earlier sentence (merge mode);check:i18n-stale-fillis green.packages/objectql/src/engine.ts, the comment above theskipAutomationsread in the hook trigger: it names "import undo" among the callers that set the flag, which stays true of the system principal's undo. An internal comment, not a published describe; left as is.docs/qa/platform-checklist/areas/records-forms.json, itemrecords-forms.import-job-undo-cancel, grades an undo that leaves rows behind a FAIL. On its fixture, with no refusing control, that still holds; where a control refuses a row, the row is now left and reported infailuresby design. Not edited.packages/spec/src/data/hook.zod.tsis also named by Hook ctx.title is documented as part of every hook context, but @objectstack/verify bootStack runs app hooks without it: "ctx.title is not a function" under the test harness, while objectstack dev serves it #24289 (a different field). Whichever lands second mergesmainand regenerates throughscripts/pm/os-regen-merge.sh.Generated by Claude Code