Repository navigation
Commit 741aa85
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
File tree
- .changeset
- content/docs
- permissions
- protocol/objectql
- references/api
- packages
- qa/dogfood/test
- rest/src
- spec/src/api
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
| 143 | + | |
143 | 144 | | |
144 | 145 | | |
145 | 146 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
329 | 329 | | |
330 | 330 | | |
331 | 331 | | |
332 | | - | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
333 | 337 | | |
334 | 338 | | |
335 | 339 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
114 | | - | |
| 114 | + | |
115 | 115 | | |
116 | | - | |
| 116 | + | |
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
| |||
0 commit comments