Repository navigation
fix(rest)!: the import doors honour a control opt-out only for the system principal - #24379
Conversation
…but the system principal Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…tem caller Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…scribes Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
… options Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 762c4cce15b082abbbf979b79b0e5e6969130f6c && git checkout 762c4cce15b082abbbf979b79b0e5e6969130f6c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5da2e88818a006145330e3f323759ebef4d6ca87 2103a762a64436a3e045616c474b8cdfe7828f30 && git checkout -B drift-repro 5da2e88818a006145330e3f323759ebef4d6ca87 && git merge --no-ff 2103a762a64436a3e045616c474b8cdfe7828f30
node scripts/docs-audit/affected-docs.mjs --json 5da2e88818a006145330e3f323759ebef4d6ca87
|
席位核对ACCEPT (lane review; ⛔ not yet queueable: a
Generated by Claude Code |
Contract reviewServed-tier: ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each named right or wrong:
② Semver levelWRONG — the failing finding. The changesets say
③ Boundary flagsDev flags (the report's
Code and tests pass ①; the record fails on ② alone. A re-review on the new head is owed once the changeset and the PR body carry the declaration. Implemented-by: VERDICT: FAIL |
…r, breaking) Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsEvery judgment of record
② Semver levelRIGHT now — the FAIL's one finding is closed.
③ Boundary flagsUnchanged from The patch round's own flags (report
No new Implemented-by: VERDICT: PASS |
席位核对 — amendment for head
|
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/importandPOST /api/v1/data/:object/import/jobs, read two request flags that take an app's declared business controls off every row they write:runAutomations: falsemakes the runner write withskipAutomations, so no metadata-bound hook and no record-change trigger runs (an approval flow and a closed-record freeze hook among them).treatAsHistorical: truemakes the runner write withskipStateMachineandpreserveAudit, so the object'sstate_machinerule 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_DENIEDin the ADR-0112 envelope when it sends either opt-out.error.detailsis{ object, refused }, whererefusednames 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 its404ahead of the refusal.What stays as it was: a request that omits both keys, or sends
runAutomations: trueortreatAsHistorical: false, imports exactly as before, with every control in force. The schema shape and both defaults do not move.runImportin@objectstack/coreis 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 (runtime: any signed-in member can run arunAs: 'system'flow throughPOST /automation/:name/trigger— the door checks only anonymity, so an elevated sub-flow is an elevated door for everyone #22310) admitsexecutionContext?.isSystem === trueand nobody else. A platform administrator's session is refused there too.packages/plugins/plugin-approvals/src/lifecycle-hooks.ts:502: the approval record lock exemptsisSystemonly. 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 hasisSystemas its only exemption. The maintainer's ruling of 2026-08-08 (recorded inpackages/objectql/src/validation/rule-validator.ts) declined to honourpreserveAuditon create becausetreatAsHistoricalarrived on an ordinary non-system import request.packages/core/src/security/assemble-execution-context.ts:337: every transport entry point assemblesisSystem: 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.tsreadImportControlFlags(:223) is the one reading of the two flags.prepareImportRequestand 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-1enforceApiAccessand beforeprepareImportRequest. The sync door asks at :293 and the job door at :410. The refusal is written through the shared envelope writer (sendErrorfrom@objectstack/types).packages/spec/src/api/export.zod.ts: therunAutomationsandtreatAsHistoricaldescribes now say who may send the opt-out. TherunAutomationsdescribe no longer promises a silent bulk load to any caller, and the schema docblock's example body no longer sendsrunAutomations: false.content/docs/references/api/export.mdxis regenerated bygen:docs.check:generatedreports all 14 artifacts up to date.content/docs/permissions/system-context.mdx: new row 30b anchors the new elevation read (check:system-context-censusrequires it).content/docs/protocol/objectql/state-machine.mdxandsecurity.mdx: the historical-import paragraphs now say the import doors admittreatAsHistorical: truefrom the system principal only.patchchangesets, one for@objectstack/restand one for@objectstack/spec.The adjacent flag:
treatAsHistorical, gated in this PRThis was measured, not assumed.
treatAsHistorical: truereachespackages/core/src/utils/import-runner.ts:766asskipStateMachine: true, preserveAudit: true. That removes three declared controls:state_machinevalidation rule (initialStateson insert,transitionson update);readonlystrip for the audit family and for author-declared business read-only fields (closed_at,resolved_byand similar);updated_at/updated_bystamps, 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:
createMissingOptionsputskeptOptionValueson 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
POST /api/v1/auth/admin/import-users(packages/plugins/plugin-auth/src/admin-import-users.ts:555, :741):runAutomations: falseis fixed server-side. The caller'streatAsHistoricalis parsed but never forwarded torunImport. Not caller-chosen.packages/services/service-automation/src/connector-pull.ts:427):runAutomations: trueandtreatAsHistorical: falseare fixed. Not caller-chosen.skipAutomations/skipTriggers(the seed loader, the comment-reactions conversion, the seed-ownership claims): all of them also run asisSystemwith fixed values./datadomain: none serves an import or forwards a skip flag (searchedpackages/mcp/src,packages/cli/src,packages/runtime/src/domains).POST /api/v1/data/import/jobs/:jobId/undo(packages/rest/src/data-action-routes.ts:647): its restore and delete writes always carryskipAutomations: true, skipStateMachine: true, andpreserveAuditfor 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 realObjectQL, the real protocol and sqlite:memory:, and asserts behaviour only. The fixtures are two metadata-bound hooks: an approval gate (beforeInsertmarks a new dealapproval_status: 'pending') and a closed-record freeze (beforeUpdaterefuses a change to a storedclosedcase). There are 16 cases:runAutomations: falseon an approval-gated insert is refused403, with no row and no job;runAutomations: falseon an update of a frozen record is refused403, and the record is unchanged;treatAsHistorical: trueis refused403the same way;details.refused.404.pending, and the frozen case's row is refused and unchanged;runAutomations: true/treatAsHistorical: falseare not opt-outs.runAutomations: falsewrites past both controls on both doors;treatAsHistorical: trueis admitted.Ablation at
0b764a4ab, throughscripts/ablation-replace.mjs. The subject resolves fromsrcby relative import, so no build is involved. The mutation turned the gate'sif (refused.length === 0) return undefined;into>= 0(anchor 1 to 0, blob7f349a61f36dtoe58774c610f7). 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 andgit diff HEADis empty.The pin sweep. Every test that sent an opt-out through a door as a non-system caller was found by
git grepover every file that names either flag. The tests that callrunImportdirectly are untouched, because the gate is at the door. Each swept test keeps its subject:import-integration.test.ts(3 sites) andimport-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.import-reference-exposure,import-user-field-targetandvalidation-message-locale: the fixtures bind no automation, so the flag is dropped.organization-update-door: thetreatAsHistoricalrequest is now refused403at the door. The same row without the flag still meets the guard's own per-rowPERMISSION_DENIED.comment-thread-parent-pair: the member's historical import is refused403and the pair is untouched. Without the flag, the member's update still stores the thread's pair. The engine-sidepreserveAudithalf is held inpackages/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 at9ee5f692e; the rest sources are unchanged since, and only the new test file's typing moved.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 typecheckexited 0, andcheck:test-typecheckis OK with 0 ledgered files.@objectstack/spec:export.test.tsandexport-job-family-retirement.test.tsgave 45 passed.pnpm --filter @objectstack/spec run typecheckexited 0, andcheck:generatedreports all 14 artifacts up to date after a rebuild.distbuilt from this source: 5 files and 40 passed.pnpm --filter @objectstack/dogfood run typecheckexited 0 with all five files in the program (--listFiles).eslint --no-inline-configover the 12 changed.tsfiles: exit 0.node scripts/pm/dispatch-gates.mjs --commandsderived 112 families at0b764a4ab. All 112 exited 0, and--ranreports 112 run, 0 NOT-MEASURED and 0 UNRUN. In the first pass,check:skill-examplesandcheck:dual-build-cjs-loadsansweredPREREQUISITE NOT METbefore the builds and went green after.check:query-options-erasurecaught one untyped options bag in the new test, which was then fixed.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
scripts/demo-staff.tsuses 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.false) and "treat as historical" to every user. Both now answer the door's403for 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.tsandcontent/docs/references/api/export.mdxare declared on [PM seat] domain:spec — ⏳ vacant #6017. This PR also edits thetreatAsHistoricaldescribe and the schema docblock's example body there, because the adjacent flag is gated in the same round.packages/core/src/utils/import-runner.tsis 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
17.import-run-automations-declared-default-corrected.tsand theIMPORT_RUN_AUTOMATIONS_REASONdefault-change record inpackages/spec/scripts/lib/default-changes.tsstill namerunAutomations: falseas the opt-out spelling. Both are records of a past release's change. The claim declaresClause-②: no, so this PR registers no new v18 entry.createMissingOptionsis 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.import-integration.test.tskeeps a pre-existing comment saying the min/max pre-check is "NOT gated onrunAutomations". It predates this change and is untouched.Generated by Claude Code