Skip to content

Commit adb7910

Browse files
fix(rest)!: an import undo runs under the object's declared controls for every caller but the system principal (#24431)
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 an `UndoImportJobResponse` must set it (the shape `ListAiConversationsResponseSchema.hasMore` set). `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. - **Whose undo skips the controls.** The reversal writes keep `skipAutomations` and `skipStateMachine` for 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 its `state_machine` rule. The predicate is the one the import doors ask before they honour a control opt-out (`importControlOptOutRefusal` in `import-prepare.ts`, landed in `741aa85da`): 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. - **A refused row is reported, never counted as reverted.** Each reversal that throws (a hook's refusal, the state machine, a record lock, or any other failure) is caught for its row. The answer lists it in a new `failures` array, one `{ id, action, code, error }` per row, where `action` is `delete` or `restore` and `code` and `error` are what `PATCH` / `DELETE /data/:object/:id` answer 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). `failed` is the length of `failures`, and `success` is `false` when any row is listed. - **The job's history says what happened.** The job row is stamped `reverted_at` only 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 under `failures`. The job stays undoable, and undoing it again retries only those rows, never one already reverted. - **Unchanged.** A historical job (`treat_as_historical`) keeps `preserveAudit` on its undo for every caller. The job-row write that records the outcome stays engine-owned (`isSystem: true`, ADR-0103). The `404`, `409 ALREADY_REVERTED` and `422 NOT_UNDOABLE` answers are unchanged. The import doors and their opt-out gate are not touched. The undo writes go through the protocol's `deleteData` / `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`: the `skipAutomations` describe on the hook context's `session` names 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`: the `skipAutomations` TSDoc says the data-import runner (`runImport`) sets it from its caller's `runAutomations: 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; the `skipStateMachine` TSDoc says the same of the undo. - `packages/spec/src/api/export.zod.ts`: `UndoImportJobResponseSchema` gains `failures`; the `success` and `failed` describes match it; `revertedAt` on `ImportJobProgressSchema` and `ImportJobSummarySchema` says it is set only once every row is reverted. - `content/docs/permissions/system-context.mdx` gains row 30c for the new `isSystem` read (`packages/rest/src/data-action-routes.ts#importUndoWriteContext`); `content/docs/protocol/objectql/state-machine.mdx` says the undo walks the state machine for every caller but the system principal. - Generated artifacts come from the generators only: `content/docs/references/api/export.mdx`, `content/docs/references/data/hook.mdx`, `packages/spec/authorable-surface/api.json` and the strictness-ledger counts. - `packages/platform-objects/src/audit/sys-import-job.object.ts`: the `undo_log` field's description adds that a partial undo keeps only the unreverted rows there, with a `failures` list giving each one's id, action, code and refusal. Its `en` i18n bundle (`packages/platform-objects/src/apps/translations/en.objects.generated.ts`) is regenerated with `node 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`, summary `fix(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`, summary `fix(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 required `failures`: a response parsed with `UndoImportJobResponseSchema` is refused without it; code constructing an `UndoImportJobResponse` must set it, `[]` on a full revert; readers of `client.data.undoImportJob` are unchanged. `failures` stays 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 a `state_machine` rule with a terminal state. 1. A signed-in caller's undo runs the object's hooks on its reversal writes and reverts every row (the default path: `success: true`, `failures: []`, the job stamped reverted). 2. The system principal's undo keeps the skips: no metadata-bound hook runs on its reversal writes. 3. A signed-in caller's undo walks the state machine: a restore it refuses is reported per row in the answer and in the job's stored undo log, is not counted as reverted, and leaves the job undoable; the system principal's second undo retries only that row and stamps the job. 4. A delete and a restore the freeze refuses are each reported per row, and the job keeps both. 5. A historical job keeps `preserveAudit` on a signed-in caller's undo, which carries no control skip. 6. A historical job keeps `preserveAudit` on 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-typecheck` OK, and `--listFiles` on `tsconfig.test.json` includes 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-config` over 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 keeps `no-record-echo`; its `why` now says the answer carries counts and per-row `failures` (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.ts` and `src/api/export.test.ts`: 70 passed. - `eslint --no-inline-config` over the four files this round changed: exit 0, no output. **Ablation of the gate** (one-off, not kept): `scripts/ablation-replace.mjs` replaced the system-principal conditional in `importUndoWriteContext` with unconditional skips (anchor hit 1 time, blob `bf6a57a75bbf` to `01b7721efd21`), 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, so `failed` read 0; the freeze refused nothing, so the delete and restore both went through; the historical undo carried `skipAutomations`). The two system-principal pins stayed green. Restore proven: blob after restore `bf6a57a75bbf` equals the blob at `HEAD`, and `git diff HEAD` is empty. The rest suite resolves `data-action-routes.ts` from `src/`, 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 --commands` printed 111 commands for this change set (14 paths vs merge base `8baeda520`, +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 --ran` over that record: "111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. Patch round 1 at head `83425d5bc`: the derivation printed 113 commands (17 paths, +584 / -48; the two new ones are `pnpm check:i18n` and `pnpm 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.md` and `24380-undo-response-failures.md`, each with signals `BREAKING+bang+clause-②-narrowing` and `not-required (no-migration-prescription)`). - `check-changeset-no-major.mjs --base origin/main`: "This diff introduces no `major` bump". 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 graded `patch`", 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-surface` unchanged; `check:authorable-surface`, `check:strictness-ledger` green. - `check:nul-bytes` OK; `check:route-envelope` green; `check:cross-package-test-inputs` OK. - `check:i18n` "OK (10 package(s) — all bundles in sync, no undeclared authoring keys)"; `check:i18n-stale-fill` OK; `check:keyed-text-bounds` green. - Local-only `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-wide `pnpm 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 merged `origin/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; the `ExecutionContext` TSDoc names `runImport`'s in-process caller), `7c779806f` (the tripwire's undo row), `483069d1f` (the `undo_log` description) and `83425d5bc` (its regenerated `en` bundle). `failures` stays required. ## Acceptance notes - objectui's Import Wizard (`packages/plugin-grid/src/ImportWizard.tsx`, `useImportJobUndo`, read at objectui `79cb8c0`) 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/:jobId` and `/results` do not project the stored `failures`; the undo answer and the job row carry them, which meets the ruling's "job's history". The three translated locales of the `undo_log` help text keep their earlier sentence (merge mode); `check:i18n-stale-fill` is green. - `packages/objectql/src/engine.ts`, the comment above the `skipAutomations` read 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`, item `records-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 in `failures` by design. Not edited. - `packages/spec/src/data/hook.zod.ts` is also named by #24289 (a different field). Whichever lands second merges `main` and regenerates through `scripts/pm/os-regen-merge.sh`. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e1ce9d commit adb7910

17 files changed

Lines changed: 584 additions & 48 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: an import undo runs under the object's hooks, record-change triggers and state machine for every caller but the system principal, and reports each row a control refuses instead of counting it as reverted
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes, and no stored shape an author owns, is added, removed, renamed or converted: the change is which controls the undo route's reversal writes run under, plus one key added to the route's answer. No spec key, export or error code is removed or renamed, and the job row's columns are unchanged (its undo log stays the engine-owned json column it was), so there is nothing for `objectstack migrate meta` to rewrite. The remedy is a run-time one, stated below. -->
10+
11+
**BREAKING** (the import undo door takes fewer controls off its writes, and claims less: `POST /api/v1/data/import/jobs/:jobId/undo` keeps `skipAutomations` and `skipStateMachine` on its reversal writes for the system principal alone, so every other caller's undo, administrators included, runs under the object's metadata-bound hooks, record-change triggers and `state_machine` rule; and an undo that leaves any row unreverted now answers `success: false` and leaves the job unstamped, where it answered `success: true` and stamped it reverted), shipped as `minor` under the launch-window convention for breaking changes. The cost is named, not hidden: undoing an ordinary import may fire record-change automation again (a notification, an approval re-opening on a restored amount), which is the app's declared behaviour for those writes. If a row now stays unreverted, read its entry in `failures`, resolve what refuses it on the app (complete the approval, reopen the record) and undo the job again, which retries only the rows that remain; a reversal that must skip the controls runs in-process as the system principal.
12+
13+
The same rule now holds at every import door: only the system principal may take an app's declared controls off a write. The import doors already refused a caller-chosen opt-out from everyone else; the undo door chose its skips itself, for every caller, and no longer does.
14+
15+
**What changes.** The undo's deletes (of the records the job created) and restores (of the fields it updated) are written as `ExecutionContext.isSystem` decides: the system principal keeps `skipAutomations` and `skipStateMachine`; every other caller writes with neither. A reversal that a hook, the state machine, a record lock or anything else refuses is caught for its row and never swallowed:
16+
17+
- the answer lists it in a new `failures` array, one `{ id, action, code, error }` per row, where `action` is `delete` or `restore` and `code` / `error` are what the single-record write door answers the same error with (a hook's refusal reads in the hook's own sentence); `failed` is its length and `success` is `false`;
18+
- the job's stored undo log is rewritten to exactly the unreverted rows, each refusal beside them under `failures`, and `reverted_at` is not stamped. The job stays `undoable`, and undoing it again retries only those rows, never one already reverted. `reverted_at` (`revertedAt` on the job's progress and history) now means every row was reverted.
19+
20+
**What stays as it was.** An undo that no control refuses reverts every row and answers as before, with `failures: []`. A historical job (`treatAsHistorical`) keeps `preserveAudit` on its undo for every caller. The job-row write that records the outcome stays engine-owned (ADR-0103). The 404, 409 `ALREADY_REVERTED` and 422 `NOT_UNDOABLE` answers are unchanged, and the import doors and their opt-out gate are untouched.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: `UndoImportJobResponseSchema` requires `failures`, one entry per row an import undo did not revert, and the `skipAutomations` describe names no import-undo exception
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No authored key and no stored shape moves: `failures` is a key on the import undo's HTTP answer, which the server builds and no author writes or stores; the job row keeps its columns, its undo log stays the engine-owned json column it was, and the other edits are describes and TSDoc. No spec key, export or error code is removed or renamed, so there is nothing for `objectstack migrate meta` to rewrite. -->
10+
11+
**BREAKING** (`UndoImportJobResponseSchema` accepts fewer answers: `failures` is required, so a response parsed with the schema is refused without it, and code that constructs an `UndoImportJobResponse` must set it, `[]` on a full revert; readers of `client.data.undoImportJob` are unchanged and gain the key), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`UndoImportJobResponseSchema` gains a required `failures`: one `{ id, action, code, error }` entry per row an import undo did not revert, `action` being `delete` or `restore`, and an empty array when every row was reverted. It is required rather than optional so a server that does not report its unreverted rows is off-contract instead of silently spec-valid. The `success` describe now says it is `false` when any row is listed there, and `failed` counts those entries. `revertedAt` on `ImportJobProgressSchema` and `ImportJobSummarySchema` now says it is set only once every row of the job is reverted; an undo that left rows unreverted sets nothing, and the job stays undoable for them.
14+
15+
The `skipAutomations` describe on the hook context's `session` no longer names the import undo as a case that skips an app's controls for any caller. It names the system principal's import and undo, and says the import undo door is not an exception: only the system principal may take an app's declared controls off a write, and any other caller's undo runs under the object's hooks, record-change triggers and state machine. The `ExecutionContext` TSDoc for `skipAutomations` says the import runner sets it from its caller's `runAutomations: 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; the `skipStateMachine` TSDoc says the same of the undo.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ that silently does not happen.
141141
| 29c | A detail write's master-detail header read asks **no read door** | objectql | Get: the write binds the header a parent-scoped `requiredWhen` / `readonlyWhen` reads as `parent` from the header's stored row whoever can read it, as before. Every caller that is not system meets its own read door first: a header that read does not return answers as a header that exists nowhere — an unbound `parent`, and `reference_not_found` on the reference check (which a system caller's write skips altogether) | `packages/objectql/src/engine.ts#masterDetailHeadersCallerReads` |
142142
| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
143143
| 30b | **The import doors admit a control opt-out** — `runAutomations: false` and `treatAsHistorical: true` on `POST /data/:object/import` and `/import/jobs` | rest | Get: an import whose rows are written with `skipAutomations` (no metadata-bound hook, no record-change trigger: an app's approvals and record freezes among them) or with `skipStateMachine` and `preserveAudit`. Every other caller, every signed-in user and administrator included, is refused `403 PERMISSION_DENIED` before the body is parsed, so nothing is written and no job is created. Inbound HTTP never carries the flag, so this is an in-process caller's door. Note: the flag still suppresses none of those controls by itself (see below); it only admits the opt-outs that do | `packages/rest/src/import-prepare.ts#importControlOptOutRefusal` |
144+
| 30c | **The import undo keeps the control skips** — `POST /data/import/jobs/:jobId/undo` | rest | Get: an undo whose deletes and restores are written with `skipAutomations` and `skipStateMachine`, so no metadata-bound hook, record-change trigger or `state_machine` rule refuses a reversal. Every other caller, every signed-in user and administrator included, undoes under those controls: a reversal one of them refuses is reported per row in the undo answer's `failures` and kept in the job's undo log, and the job is stamped reverted only once every row is. A historical job's `preserveAudit` belongs to the job, not the caller, and rides every undo. Inbound HTTP never carries the flag, so this is an in-process caller's door | `packages/rest/src/data-action-routes.ts#importUndoWriteContext` |
144145

145146
### 3. Sharing (`plugin-sharing`)
146147

‎content/docs/protocol/objectql/state-machine.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ transitions: {
115115
- **`treatAsHistorical` also preserves the original audit timeline** (#3493) — **on the rows an import UPDATES** (#6640). Skipping the FSM is only half of migrating established facts; the other half is keeping *when* they happened and *who* did them. Under the same flag the write context also carries `preserveAudit`, which (1) makes `updated_at` / `updated_by` **client-preferred** — a supplied historical last-modified survives instead of being stamped with the import instant — and (2) admits a **whitelist** through the static-`readonly` write strip: the audit/timestamp family plus author-declared business `readonly` fields (`closed_at`, `resolved_by`, …) — but never the record's own primary key (`id`), which is the address of the write rather than a fact being restored (#8215). Platform-managed `system` columns outside that family (`organization_id` and other tenancy/generated columns) stay stripped — a historical import reinstates facts, it does not forge tenancy. Like the FSM exemption this is opt-in: a normal write still auto-stamps `updated_at`/`updated_by` and strips `readonly` exactly as before, and permissions / RLS / field-level security are unchanged.
116116
- **…but a historical `upsert` still drops those columns from the rows it CREATES** (#6640). `preserveAudit` is an **UPDATE-path exemption and nothing else reads it**: both write paths run the same in-engine strip (`stripReadonlyFields`, #14147), but only the UPDATE call site passes `preserveAudit` — the create side is given no such flag and keeps `context.isSystem` as its only exemption. So a single `treatAsHistorical` upsert keeps `closed_at` on the rows it **matches** and strips it — together with a supplied `created_at` / `updated_at`, which the injected audit columns also declare `readonly` — from the rows it **inserts**. The asymmetry is deliberate, not an oversight: `treatAsHistorical` arrives on an ordinary (non-system) import request, so honouring it on create would let any caller seed the approval/status columns that create-side strip exists to protect. The ignored request is at least no longer silent — the server logs a `WARN` naming the object, the stripped fields and this UPDATE-only rule — but the strip still applies. **To replay archival read-only facts on the rows an import creates, write from a system context** (`isSystem`). Over HTTP this case no longer arises: the import doors admit `treatAsHistorical: true` from the system principal alone, whose creates the strip exempts; it still holds for an in-process, non-system caller of the import runner. Full rule and rationale: [Security & Access Control](/docs/protocol/objectql/security).
117117
- **Undoing a historical import is symmetric** (#3549 / #3556). The import undo (`POST /api/v1/data/import/jobs/:jobId/undo`) logically rolls back a finished job — deleting the rows it created and restoring the captured pre-import snapshot on the rows it updated. That restore write now carries `preserveAudit` too, but **only** when the job was flagged `treatAsHistorical`, so the snapshotted `updated_at` / `updated_by` and business `readonly` fields (`closed_at`, …) are reinstated verbatim instead of being re-stamped to the undo instant. The undo is unaffected by the create-side carve-out above: it only ever *deletes* the rows the import created and *updates* the rows it touched, so every write it makes is on the path where the exemption is real. Without it the undo would silently overwrite the very timeline the historical import preserved; a normal (non-historical) import's undo keeps the default stamp/strip.
118+
- **The undo walks the state machine for every caller but the system principal.** Restoring a snapshot is a transition like any other write, so a signed-in caller's undo, historical job or not, runs under the object's `state_machine` rule, its hooks and its record-change triggers; only the system principal's undo skips them, the same rule the import doors keep for `treatAsHistorical`. A restore the rule refuses is not counted as reverted: the undo answer lists it in `failures` with its code and message, the job keeps that row in its undo log and is not stamped `revertedAt`, and undoing the job again retries only the rows that remain.
118119

119120
### Conditional transitions
120121

‎content/docs/references/api/export.mdx‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ const result = CreateImportJobRequestSchema.parse(data);
182182
| **errors** | `integer` | ✅ | Rows that failed |
183183
| **percentComplete** | `number` | ✅ | processed / total as a percentage |
184184
| **undoable** | `boolean` | ✅ | Whether this job can still be logically rolled back (undo log captured, terminal state, not yet reverted) |
185-
| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601) |
185+
| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601): set only once every row is reverted. An undo that left rows unreverted sets nothing, and the job stays undoable for those rows. |
186186
| **error** | `string` | optional | Fatal error message (when status = failed) |
187187
| **startedAt** | `string` | optional | Processing start timestamp (ISO 8601) |
188188
| **completedAt** | `string` | optional | Completion timestamp (ISO 8601) |
@@ -210,7 +210,7 @@ const result = CreateImportJobRequestSchema.parse(data);
210210
| **errors** | `integer` | ✅ | Rows that failed |
211211
| **percentComplete** | `number` | ✅ | processed / total as a percentage |
212212
| **undoable** | `boolean` | ✅ | Whether this job can still be logically rolled back (undo log captured, terminal state, not yet reverted) |
213-
| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601) |
213+
| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601): set only once every row is reverted. An undo that left rows unreverted sets nothing, and the job stays undoable for those rows. |
214214
| **error** | `string` | optional | Fatal error message (when status = failed) |
215215
| **startedAt** | `string` | optional | Processing start timestamp (ISO 8601) |
216216
| **completedAt** | `string` | optional | Completion timestamp (ISO 8601) |
@@ -266,7 +266,7 @@ const result = CreateImportJobRequestSchema.parse(data);
266266
| **createdAt** | `string` | ✅ | Job creation timestamp (ISO 8601) |
267267
| **completedAt** | `string` | optional | Completion timestamp (ISO 8601) |
268268
| **undoable** | `boolean` | ✅ | Whether this job can still be logically rolled back |
269-
| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601) |
269+
| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601): set only once every row is reverted. An undo that left rows unreverted sets nothing, and the job stays undoable for those rows. |
270270

271271

272272
---
@@ -533,7 +533,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
533533
| **createdAt** | `string` | ✅ | Job creation timestamp (ISO 8601) |
534534
| **completedAt** | `string` | optional | Completion timestamp (ISO 8601) |
535535
| **undoable** | `boolean` | ✅ | Whether this job can still be logically rolled back |
536-
| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601) |
536+
| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601): set only once every row is reverted. An undo that left rows unreverted sets nothing, and the job stays undoable for those rows. |
537537

538538

539539
---
@@ -544,12 +544,22 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
544544

545545
| Property | Type | Required | Description |
546546
| :--- | :--- | :--- | :--- |
547-
| **success** | `boolean` | ✅ | Whether the undo completed |
547+
| **success** | `boolean` | ✅ | Whether every row was reverted. False when any row is listed in `failures`: the undo did not complete, and the job is not stamped reverted. |
548548
| **jobId** | `string` | ✅ | Import job id |
549549
| **object** | `string` | ✅ | Target object name |
550550
| **deleted** | `integer` | ✅ | Created records deleted |
551551
| **restored** | `integer` | ✅ | Updated records restored to pre-import values |
552-
| **failed** | `integer` | ✅ | Reversal operations that failed |
552+
| **failed** | `integer` | ✅ | Rows the undo did not revert: one per entry in `failures` |
553+
| **failures** | `{ id: string; action: Enum<'delete' \| 'restore'>; code: string; error: string }[]` | ✅ | One entry per row the undo did not revert, in the order the undo tried them (deletes first). For every caller but the system principal the reversal writes run under the object's hooks, record-change triggers and `state_machine` rule, so a row one of them refuses is listed here, as is a write that failed for any other reason. The job keeps these rows, each with its entry, in its undo log and stays undoable: undoing it again retries only them. Empty when every row was reverted. |
554+
555+
### Nested Shape: `UndoImportJobResponse.failures[number]`
556+
557+
| Property | Type | Required | Description |
558+
| :--- | :--- | :--- | :--- |
559+
| **id** | `string` | ✅ | Id of the record the reversal targeted |
560+
| **action** | `Enum<'delete' \| 'restore'>` | ✅ | The reversal that did not happen: `delete` for a record the import created, `restore` for one it updated |
561+
| **code** | `string` | ✅ | Error code the write was refused or failed with, as the single-record write door answers it |
562+
| **error** | `string` | ✅ | Human-readable message: a refusing hook or validation rule's own sentence, or the door's withheld-fault sentence |
553563

554564

555565
---

0 commit comments

Comments
 (0)