Skip to content

Commit 741aa85

Browse files
fix(rest)!: the import doors honour a control opt-out only for the system principal (#24379)
Fixes #24348 Clause-②: no (narrowing) Basis for the Clause-② line (copied from the claim, `6252062987`): a narrowing. The import door stops accepting an opt-out from an unentitled caller, and nothing newly accepted or exported is added. ## What changes The two import doors, `POST /api/v1/data/:object/import` and `POST /api/v1/data/:object/import/jobs`, read two request flags that take an app's declared business controls off every row they write: - `runAutomations: false` makes the runner write with `skipAutomations`, so no metadata-bound hook and no record-change trigger runs (an approval flow and a closed-record freeze hook among them). - `treatAsHistorical: true` makes the runner write with `skipStateMachine` and `preserveAudit`, so the object's `state_machine` rule is skipped and supplied audit and read-only values are kept. **Who may opt out:** only the system principal, `ExecutionContext.isSystem === true`, which is an in-process caller such as a seed or migration job. **What everyone else gets:** every other caller, every signed-in user and administrator included, gets `403 PERMISSION_DENIED` in the ADR-0112 envelope when it sends either opt-out. `error.details` is `{ object, refused }`, where `refused` names the opt-outs it sent. The message names the flags, what each removes, and who may send them. The doors ask before the body is parsed, so a refused request writes no row and creates no import job. A dry run that sends an opt-out is refused too. An object the API does not expose keeps its `404` ahead of the refusal. **What stays as it was:** a request that omits both keys, or sends `runAutomations: true` or `treatAsHistorical: false`, imports exactly as before, with every control in force. The schema shape and both defaults do not move. `runImport` in `@objectstack/core` is unchanged, so in-process callers keep their own settings. The refusal is loud by design. A caller that asked for a silent load must learn it did not get one, so the flag is never silently ignored. ## The predicate reused (premise verified first) No permission key, capability name or config switch is added. The gate reuses the platform's existing answer to "who may get past an app's business controls", which is the system principal: - `packages/runtime/src/flow-start-admission.ts:114`, `refusesElevatedSelfTriggeredStart`: the elevated self-triggered start door (#22310) admits `executionContext?.isSystem === true` and nobody else. A platform administrator's session is refused there too. - `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts:502`: the approval record lock exempts `isSystem` only. It deliberately has no administrator exemption: an administrator releases a locked record through the audited approval rescue, not by writing past it. - `packages/objectql/src/engine.ts` `#insert`: the create-side read-only strip has `isSystem` as its only exemption. The maintainer's ruling of 2026-08-08 (recorded in `packages/objectql/src/validation/rule-validator.ts`) declined to honour `preserveAudit` on create because `treatAsHistorical` arrived on an ordinary non-system import request. - `packages/core/src/security/assemble-execution-context.ts:337`: every transport entry point assembles `isSystem: false`, so no HTTP caller carries the flag. Administrator standing (`posture === 'PLATFORM_ADMIN'`, `admin_full_access`) was measured as the alternative. It lets a caller past record access (RLS, sharing), but nothing in the tree lets it past an app's hooks, triggers, state machine or approval lock. Entitling administrators here would therefore be a new decision, not a reuse, so this PR does not make it. ## Code positions - `packages/rest/src/import-prepare.ts` - `readImportControlFlags` (:223) is the one reading of the two flags. `prepareImportRequest` and the gate both use it, so the gate cannot judge a different reading from the one the runner gets. - `importControlOptOutRefusal` (:279) is the gate. The system-principal check is at :285. - `packages/rest/src/data-action-routes.ts`: both doors ask the gate after the stage-1 `enforceApiAccess` and before `prepareImportRequest`. The sync door asks at :293 and the job door at :410. The refusal is written through the shared envelope writer (`sendError` from `@objectstack/types`). - `packages/spec/src/api/export.zod.ts`: the `runAutomations` and `treatAsHistorical` describes now say who may send the opt-out. The `runAutomations` describe no longer promises a silent bulk load to any caller, and the schema docblock's example body no longer sends `runAutomations: false`. `content/docs/references/api/export.mdx` is regenerated by `gen:docs`. `check:generated` reports all 14 artifacts up to date. - `content/docs/permissions/system-context.mdx`: new row 30b anchors the new elevation read (`check:system-context-census` requires it). - `content/docs/protocol/objectql/state-machine.mdx` and `security.mdx`: the historical-import paragraphs now say the import doors admit `treatAsHistorical: true` from the system principal only. - Two `patch` changesets, one for `@objectstack/rest` and one for `@objectstack/spec`. ## The adjacent flag: `treatAsHistorical`, gated in this PR This was measured, not assumed. `treatAsHistorical: true` reaches `packages/core/src/utils/import-runner.ts:766` as `skipStateMachine: true, preserveAudit: true`. That removes three declared controls: - the object's `state_machine` validation rule (`initialStates` on insert, `transitions` on update); - on UPDATE, the static `readonly` strip for the audit family and for author-declared business read-only fields (`closed_at`, `resolved_by` and similar); - the `updated_at` / `updated_by` stamps, which become client-preferred. Each of those is a declared business control, so the flag gets the same gate. One other body flag changes the write context: `createMissingOptions` puts `keptOptionValues` on the write. It was measured too. It runs no hook, validation rule, state rule or approval differently, and it admits only the exact off-list option values that one import kept. Its widening was a deliberate enforce-route decision, so it is not gated here (see Acceptance notes). ## The family: every other door that reaches the runner or a skip flag - **Sync and async import doors:** caller-chosen values. Gated here. - **`POST /api/v1/auth/admin/import-users`** (`packages/plugins/plugin-auth/src/admin-import-users.ts:555`, :741): `runAutomations: false` is fixed server-side. The caller's `treatAsHistorical` is parsed but never forwarded to `runImport`. Not caller-chosen. - **Connector pull** (`packages/services/service-automation/src/connector-pull.ts:427`): `runAutomations: true` and `treatAsHistorical: false` are fixed. Not caller-chosen. - **Server-side writers that set `skipAutomations` / `skipTriggers`** (the seed loader, the comment-reactions conversion, the seed-ownership claims): all of them also run as `isSystem` with fixed values. - **MCP, the CLI, and the runtime dispatcher's `/data` domain:** none serves an import or forwards a skip flag (searched `packages/mcp/src`, `packages/cli/src`, `packages/runtime/src/domains`). - **The import undo door, `POST /api/v1/data/import/jobs/:jobId/undo`** (`packages/rest/src/data-action-routes.ts:647`): its restore and delete writes always carry `skipAutomations: true, skipStateMachine: true`, and `preserveAudit` for a historical job. The value is server-chosen, not caller-chosen, so this door is not the same shape and is not gated here. It is reported with its measured reading to the seat, detail withheld, because what an undo owes an app's controls is a separate decision. ## Tests New file: `packages/rest/src/import-control-opt-out-gate.test.ts`. It drives the real routes over a real `ObjectQL`, the real protocol and sqlite `:memory:`, and asserts behaviour only. The fixtures are two metadata-bound hooks: an approval gate (`beforeInsert` marks a new deal `approval_status: 'pending'`) and a closed-record freeze (`beforeUpdate` refuses a change to a stored `closed` case). There are 16 cases: - **A signed-in caller who is not the system principal**, on both doors: - `runAutomations: false` on an approval-gated insert is refused `403`, with no row and no job; - `runAutomations: false` on an update of a frozen record is refused `403`, and the record is unchanged; - `treatAsHistorical: true` is refused `403` the same way; - both opt-outs together are named together in `details.refused`. - **Edges:** - a dry run with the opt-out is refused; - a hidden object keeps its `404`. - **Positive controls:** - the same caller without the opt-out imports on both doors with both controls in force: the deal lands `pending`, and the frozen case's row is refused and unchanged; - explicit `runAutomations: true` / `treatAsHistorical: false` are not opt-outs. - **The system principal keeps the opt-out:** - `runAutomations: false` writes past both controls on both doors; - `treatAsHistorical: true` is admitted. **Ablation** at `0b764a4ab`, through `scripts/ablation-replace.mjs`. The subject resolves from `src` by relative import, so no build is involved. The mutation turned the gate's `if (refused.length === 0) return undefined;` into `>= 0` (anchor 1 to 0, blob `7f349a61f36d` to `e58774c610f7`). Result: **9 failed, 7 passed**. Exactly the nine refusal pins went red. The hidden-object 404, the positive controls and the system-principal cases stayed green. Restore: the blob equals HEAD and `git diff HEAD` is empty. **The pin sweep.** Every test that sent an opt-out through a door as a non-system caller was found by `git grep` over every file that names either flag. The tests that call `runImport` directly are untouched, because the gate is at the door. Each swept test keeps its subject: - `import-integration.test.ts` (3 sites) and `import-dryrun-parity.test.ts` (2): the fixtures bind no automation, so the flag changed nothing. It is dropped. - `import-job-integration.test.ts`: the historical-undo case now creates its historical job as the system principal. The undo is still the member's. - dogfood `import-reference-exposure`, `import-user-field-target` and `validation-message-locale`: the fixtures bind no automation, so the flag is dropped. - dogfood `organization-update-door`: the `treatAsHistorical` request is now refused `403` at the door. The same row without the flag still meets the guard's own per-row `PERMISSION_DENIED`. - dogfood `comment-thread-parent-pair`: the member's historical import is refused `403` and the pair is untouched. Without the flag, the member's update still stores the thread's pair. The engine-side `preserveAudit` half is held in `packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts`. ## Tests and gates (head `0b764a4ab`) - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: 296 files, 6067 passed, 326 skipped, exit 0. Run at `9ee5f692e`; the rest sources are unchanged since, and only the new test file's typing moved. - At `0b764a4ab`: the six import suites (`import-control-opt-out-gate`, `import-integration`, `import-dryrun-parity`, `import-job-integration`, `import-run-automations-agreement`, `import-prepare`) gave 6 files and 101 passed. `pnpm --filter @objectstack/rest run typecheck` exited 0, and `check:test-typecheck` is OK with 0 ledgered files. - `@objectstack/spec`: `export.test.ts` and `export-job-family-retirement.test.ts` gave 45 passed. `pnpm --filter @objectstack/spec run typecheck` exited 0, and `check:generated` reports all 14 artifacts up to date after a rebuild. - Dogfood, the five edited files against a rest `dist` built from this source: 5 files and 40 passed. `pnpm --filter @objectstack/dogfood run typecheck` exited 0 with all five files in the program (`--listFiles`). - `eslint --no-inline-config` over the 12 changed `.ts` files: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 112 families at `0b764a4ab`. All 112 exited 0, and `--ran` reports 112 run, 0 NOT-MEASURED and 0 UNRUN. In the first pass, `check:skill-examples` and `check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET` before the builds and went green after. `check:query-options-erasure` caught one untyped options bag in the new test, which was then fixed. - The ten declared wide-population families (`init-service-contract`, `live-db-isolation`, `meta-type-normalized`, `optional-error-sink`, `resume-authority-declared`, `runner-env-posture`, `settings-bind-window`, `startup-registry-verdict`, `verify-stand-in`, `wildcard-fallthrough`): all exited 0. - `check:system-context-census`: OK, 143 read sites, 137 of 137 symbols cited. ## Consumers - **objectstack-ai/hotcrm's dev-only `scripts/demo-staff.ts`** uses this door as the dev administrator. An administrator's session is not the system principal, so that script's opt-out is now refused and it exits by its own submitter check. The card names that as the intended loud failure. - **objectui's Import Wizard** offers "run automations" (unchecked sends `false`) and "treat as historical" to every user. Both now answer the door's `403` for every signed-in user. Hiding or dropping those two options is objectui's surface, and this PR leaves it for the seat to raise there. ## Cross-lane declarations - `packages/spec/src/api/export.zod.ts` and `content/docs/references/api/export.mdx` are declared on #6017. This PR also edits the `treatAsHistorical` describe and the schema docblock's example body there, because the adjacent flag is gated in the same round. - `packages/core/src/utils/import-runner.ts` is **not** touched. The gate lives at the door, because every caller-chosen value enters through the two REST doors and every in-process caller fixes its own. ## Acceptance notes - **Historical registry records are left as is.** The v17 semantic migration entry `17.import-run-automations-declared-default-corrected.ts` and the `IMPORT_RUN_AUTOMATIONS_REASON` default-change record in `packages/spec/scripts/lib/default-changes.ts` still name `runAutomations: false` as the opt-out spelling. Both are records of a past release's change. The claim declares `Clause-②: no`, so this PR registers no new v18 entry. - **`createMissingOptions` is not gated.** It admits off-list option values for the exact cells one import kept, and it skips no hook, validation rule, state rule or approval. The enforce route that widened it was a deliberate decision. Whether option membership counts as a business control the opt-out rule should cover is left to the seat. - **One stale comment is left alone.** `import-integration.test.ts` keeps a pre-existing comment saying the min/max pre-check is "NOT gated on `runAutomations`". It predates this change and is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2e7870 commit 741aa85

18 files changed

Lines changed: 521 additions & 46 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: the import doors honour `runAutomations: false` and `treatAsHistorical: true` only for the system principal, and refuse them `403 PERMISSION_DENIED` to every other caller
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes, and no stored shape, is added, removed, renamed or converted: the change is who may send two request flags on the two import doors. Both keys stay declared on `ImportRequestSchema` with their shape and defaults unchanged, the refused values arrive in a request body and are never stored, and no spec key, export or error code is added or removed (`PERMISSION_DENIED` is a standard code), so there is nothing for `objectstack migrate meta` to rewrite. The remedy is a request-time one, stated below: send the import without the two opt-outs. -->
10+
11+
**BREAKING** (the import doors accept fewer request values: `runAutomations: false` and `treatAsHistorical: true` are refused from every caller but the system principal, administrators included), shipped as `minor` under the launch-window convention for breaking changes. Two known consumers break visibly: objectui's Import Wizard, whose two options now answer `403` for every signed-in user (#24381), and hotcrm's dev-only demo script, which sent the opt-out as the dev administrator and now exits by its own submitter check.
12+
13+
Both flags take an app's declared business controls off every row an import writes. `runAutomations: false` writes with `skipAutomations`, so no metadata-bound hook and no record-change trigger runs (an approval flow and a closed-record freeze hook among them). `treatAsHistorical: true` writes with `skipStateMachine` and `preserveAudit`, so the object's `state_machine` rule is skipped and supplied audit and read-only values are kept. Until now, any caller who may import could send either.
14+
15+
**What changes.** `POST /api/v1/data/:object/import` and `POST /api/v1/data/:object/import/jobs` admit either opt-out only from the system principal (`ExecutionContext.isSystem`), the predicate the doors that start an elevated self-triggered flow already ask. Every other caller, every signed-in user and administrator included, gets `403 PERMISSION_DENIED` in the ADR-0112 envelope, with `error.details: { object, refused }` naming the opt-outs it sent. The doors ask before the body is parsed: a refused request writes no row and creates no import job, on a dry run too. A hidden object keeps its `404` ahead of the refusal.
16+
17+
**What stays as it was.** A request that omits both keys, or sends `runAutomations: true` / `treatAsHistorical: false`, imports exactly as before, with every control in force. `runImport` (`@objectstack/core`) is unchanged: in-process callers that run as the system principal, and the identity import in `@objectstack/plugin-auth`, keep their own settings.
18+
19+
**If an import now answers 403.** Send it without `runAutomations: false` and `treatAsHistorical: true`: the rows are written with the object's automations, hooks, `state_machine` rule and audit stamps in force. A bulk load that must skip them is a migration, and runs in-process as the system principal.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Clause-②: no
6+
7+
The `runAutomations` and `treatAsHistorical` describes on `ImportRequestSchema` (and so `CreateImportJobRequestSchema`, the same schema) now say who may send the opt-out: only the system principal, an in-process caller such as a seed or migration job. The import doors refuse `runAutomations: false` and `treatAsHistorical: true` from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. The `runAutomations` describe no longer tells a caller that it gets a silent bulk load by sending `false`, and the schema's example body no longer sends it. The schema shape and both defaults are unchanged.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ that silently does not happen.
140140
| 29b | A traversing validation rule's related read skips the **caller's bounds** | objectql | Get: no row bound from the caller's own read on a related object no organization wall scopes, no read door on a header the rule reads through a `master_detail` field, and a related read even with no organization under a walled posture (a `tenantId` on the context still scopes it). Every caller that is not system is held to all three: a header its read door does not return is unavailable to the rule exactly as a header that exists nowhere is, whatever it holds, and a header it can read is judged on its stored values. A plain `lookup` to an object an organization wall scopes asks no read door, system caller or not | `packages/objectql/src/engine.ts#resolveOneHopRelated` |
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` |
143+
| 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` |
143144

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

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,11 @@ That asymmetry is deliberate, not an oversight: `treatAsHistorical` arrives on a
329329
(non-system) REST import request, so honouring it on create would let any caller seed the
330330
approval/status columns the create-side strip exists to protect — in a single POST. **To
331331
replay archival read-only facts on create, write from a system context** (`isSystem`), the
332-
same trust marker rule 1 already names.
332+
same trust marker rule 1 already names. The REST import doors now hold that line at the door
333+
as well: they refuse `treatAsHistorical: true` from every caller but the system principal,
334+
`403 PERMISSION_DENIED` before a row is read, so over HTTP the flag no longer reaches the
335+
strip at all; the UPDATE-only rule still binds an in-process, non-system caller of the import
336+
runner.
333337

334338
A non-system create that *requests* `preserveAudit` and loses fields to the strip is not
335339
silently ignored: the server logs a `WARN` naming the object, the stripped fields, and this

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,9 @@ transitions: {
111111
- The check is **lenient where it cannot reason**: if the prior state has no key in `transitions` (legacy or externally-written data, or a state you simply forgot to declare), it does not block. Only an explicit `[]` makes a state a hard dead-end.
112112
- Only a rule with `severity: 'error'` (the default) blocks the write. `warning`/`info` are advisory: logged per write on an ordinary write, folded into **one summary line per rule** on a seed/boot load, and not evaluated at all on an `update` whose payload touches only platform-injected system columns (#13889).
113113
- **Seed writes are exempt** (#3433). Curated seed data — package bootstrap fixtures, marketplace templates, per-org replay, all loaded by `SeedLoaderService` — is a snapshot of established facts, not a record walking its lifecycle, so it bypasses the `state_machine` rule entirely: a seed may be born mid-lifecycle (a `completed` project, a `closed_won` opportunity) and neither `initialStates` (insert) nor `transitions` (update) is enforced. Every *other* validation still runs, so a seed must still satisfy field shape, `format`, `script`, and the rest. `os lint` warns when a seeded value is not a state the machine declares, so a typo is still caught before boot.
114-
- **A "historical" data import is exempt too** (#3479). Migrating established facts — a batch of already-`closed` tickets, `closed_won` deals — is the same "snapshot, not a lifecycle event" situation. Set `treatAsHistorical: true` on the import request (default **off**) and the runner puts `skipStateMachine` on the write context, so `initialStates` doesn't reject those mid-lifecycle rows. A normal import leaves it off and still walks the FSM — the strict behavior is the default, so the exemption is always an explicit opt-in.
114+
- **A "historical" data import is exempt too** (#3479). Migrating established facts — a batch of already-`closed` tickets, `closed_won` deals — is the same "snapshot, not a lifecycle event" situation. Set `treatAsHistorical: true` on the import request (default **off**) and the runner puts `skipStateMachine` on the write context, so `initialStates` doesn't reject those mid-lifecycle rows. A normal import leaves it off and still walks the FSM — the strict behavior is the default, so the exemption is always an explicit opt-in. **Only the system principal may send it**: the REST import doors (`POST /api/v1/data/:object/import` and `/import/jobs`) refuse `treatAsHistorical: true` from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before a row is read, because it takes the object's `state_machine` rule and its read-only and audit protection off the write. An in-process import run as the system principal (a seed or migration job) keeps it.
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.
116-
- **…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`). Full rule and rationale: [Security & Access Control](/docs/protocol/objectql/security).
116+
- **…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.
118118

119119
### Conditional transitions

0 commit comments

Comments
 (0)