Repository navigation
Commit adb7910
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
- .changeset
- content/docs
- permissions
- protocol/objectql
- references
- api
- data
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- platform-objects/src
- apps/translations
- audit
- rest/src
- spec
- authorable-surface
- src
- api
- data
- kernel
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
| 144 | + | |
144 | 145 | | |
145 | 146 | | |
146 | 147 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
115 | 115 | | |
116 | 116 | | |
117 | 117 | | |
| 118 | + | |
118 | 119 | | |
119 | 120 | | |
120 | 121 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
182 | 182 | | |
183 | 183 | | |
184 | 184 | | |
185 | | - | |
| 185 | + | |
186 | 186 | | |
187 | 187 | | |
188 | 188 | | |
| |||
210 | 210 | | |
211 | 211 | | |
212 | 212 | | |
213 | | - | |
| 213 | + | |
214 | 214 | | |
215 | 215 | | |
216 | 216 | | |
| |||
266 | 266 | | |
267 | 267 | | |
268 | 268 | | |
269 | | - | |
| 269 | + | |
270 | 270 | | |
271 | 271 | | |
272 | 272 | | |
| |||
533 | 533 | | |
534 | 534 | | |
535 | 535 | | |
536 | | - | |
| 536 | + | |
537 | 537 | | |
538 | 538 | | |
539 | 539 | | |
| |||
544 | 544 | | |
545 | 545 | | |
546 | 546 | | |
547 | | - | |
| 547 | + | |
548 | 548 | | |
549 | 549 | | |
550 | 550 | | |
551 | 551 | | |
552 | | - | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
553 | 563 | | |
554 | 564 | | |
555 | 565 | | |
| |||
0 commit comments