Skip to content

fix(rest)!: the import doors honour a control opt-out only for the system principal - #24379

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-24348-import-automation-optout-gate
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-24348-import-automation-optout-gate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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 (runtime: any signed-in member can run a runAs: 'system' flow through POST /automation/:name/trigger — the door checks only anonymity, so an elevated sub-flow is an elevated door for everyone #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 [PM seat] domain:spec — ⏳ vacant #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

…but the system principal

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/spec, touching 17 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via /:object/import (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it))
  • content/docs/api/wire-format.mdx (via /:object/import (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it))
  • content/docs/data-modeling/fields.mdx (via /:object/import (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it))
  • content/docs/data-modeling/import-mappings.mdx (via runAutomations (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), treatAsHistorical (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), runAutomations (literal, a string literal in importControlOptOutRefusal), treatAsHistorical (literal, a string literal in importControlOptOutRefusal), /:object/import (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it), /:object/import/jobs (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runAutomations — its route source's handler names it; bridged from symbol treatAsHistorical — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via importControlOptOutRefusal (symbol, a top-level function), runAutomations (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), treatAsHistorical (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), runAutomations (literal, a string literal in importControlOptOutRefusal), treatAsHistorical (literal, a string literal in importControlOptOutRefusal), /:object/import (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it))
  • content/docs/protocol/objectql/security.mdx (via treatAsHistorical (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), treatAsHistorical (literal, a string literal in importControlOptOutRefusal))
  • content/docs/protocol/objectql/state-machine.mdx (via treatAsHistorical (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), treatAsHistorical (literal, a string literal in importControlOptOutRefusal), /:object/import (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it), /:object/import/jobs (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runAutomations — its route source's handler names it; bridged from symbol treatAsHistorical — its route source's handler names it))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via /:object/import (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it))
  • content/docs/releases/v15.mdx (via runAutomations (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), runAutomations (literal, a string literal in importControlOptOutRefusal))
  • content/docs/releases/v17/17-0.mdx (via treatAsHistorical (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), treatAsHistorical (literal, a string literal in importControlOptOutRefusal))
  • content/docs/releases/v17/17-6.mdx (via /:object/import (route, bridged from symbol importControlOptOutRefusal — its route source's handler names it; bridged from symbol prepareImportRequest — its route source's handler names it))
  • content/docs/releases/v17/index.mdx (via runAutomations (symbol, a field of const object IMPORT_CONTROL_OPT_OUT_EFFECT), runAutomations (literal, a string literal in importControlOptOutRefusal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 41 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5da2e88818a006145330e3f323759ebef4d6ca87 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 762c4cce15b082abbbf979b79b0e5e6969130f6c — the merge of head 2103a762a64436a3e045616c474b8cdfe7828f30 into base 5da2e88818a006145330e3f323759ebef4d6ca87, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5da2e88818a006145330e3f323759ebef4d6ca87 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT (lane review; ⛔ not yet queueable: a CONTRACT_REVIEW_TIER review is owed) · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T17:58Z · on head 0b764a4ab · #24348 (P0, claim 6252062987, report 6253157395). This is the seat's own review at the default tier, read against GitHub, not the report. No reproduction is written here (the disclosure ruling, 2026-08-18, #9387).

  1. Shape:
    • Draft against main. The first line is Fixes objectstack-ai/objectstack#24348.
    • Clause-②: no is line-initial in the body and both changesets. Its basis is a narrowing: the import doors stop accepting two opt-outs from non-system callers.
    • Assignee zhuangjianguo. 18 files, +517 / −46. patch changesets on @objectstack/rest and @objectstack/spec. Not governed, under 3,000.
  2. The gate, read on the diff:
    • importControlOptOutRefusal (packages/rest/src/import-prepare.ts) is asked by both doors (data-action-routes.ts) after enforceApiAccess, so a hidden object keeps its 404, and before the body is parsed. A refused request writes no row and creates no job, dry run included.
    • It refuses runAutomations: false and treatAsHistorical: true unless isSystem === true, with 403 PERMISSION_DENIED in the ADR-0112 envelope and details { object, refused }.
    • The flags are read once (readImportControlFlags), by the gate and the parser alike, so the gate cannot judge a different reading than the runner gets.
    • The default and an explicit runAutomations: true / treatAsHistorical: false pass untouched.
  3. The ruling's premise, verified by the dev: the predicate is the existing system principal, the same answer refusesElevatedSelfTriggeredStart gives (packages/runtime/src/flow-start-admission.ts:114, the runtime: any signed-in member can run a runAs: 'system' flow through POST /automation/:name/trigger — the door checks only anonymity, so an elevated sub-flow is an elevated door for everyone #22310 door). No permission, capability or config key was added. The adjacent flag (treatAsHistorical) removes declared controls (the state machine, the read-only strip on UPDATE, the audit stamps), so it is gated in the same PR, as the ruling required.
  4. Family: every door reaching the runner or a skip flag is enumerated in the PR body. The connector pull and the server-side writers use fixed values as isSystem. MCP, the CLI and the dispatcher serve no import.
  5. Tests:
    • The new import-control-opt-out-gate.test.ts runs on a real route, ObjectQL and SQLite, with an approval-gate hook and a freeze hook as fixtures: 16 passed. The six import suites: 101 passed. Full rest --project local: 296 files.
    • The ablation (the gate's empty-refusal guard inverted) turns exactly the 9 refusal pins red, and leaves the 404-ordering pin and the system-principal cases green.
    • The pin sweep updated 3 rest and 5 dogfood tests. Two of them now assert the 403 at the door.
  6. Prose, checked sentence by sentence against the diff (the seat's share):
  7. The dev's open questions:
  8. Gates: locally, 112 derived families exit 0 with 0 NOT-MEASURED. The 10 declared-wide families exit 0. check:generated and the system-context census exit 0, and eslint exits 0. CI on this head is running at this stamp.
  9. Landing:
    • ⛔ Not ready until a ## Contract review record at CONTRACT_REVIEW_TIER reads PASS on the current head. Its scope is the whole non-prose diff, because this is a narrowing, not only the schema hits. needs:contract-review is on the PR until then.
    • After that, ready plus auto-merge through the queue once every check is green.
    • Triage named the next cut (chore: version packages (next) #24168) for the release; that is not this seat's act.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0b764a4ab7aa704685c1eabe469241d633e7ee5f
Local-runs: none
Scope: packages/spec/src/api/export.zod.ts + the whole non-prose diff (narrowing face): packages/rest/src/import-prepare.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/import-control-opt-out-gate.test.ts, packages/rest/src/import-integration.test.ts, packages/rest/src/import-dryrun-parity.test.ts, packages/rest/src/import-job-integration.test.ts, the five packages/qa/dogfood/test/*.dogfood.test.ts (comment-thread-parent-pair, import-reference-exposure, import-user-field-target, organization-update-door, validation-message-locale), .changeset/24348-import-control-opt-out-gate.md, .changeset/24348-import-opt-out-describes.md. Cited code read at the head: packages/rest/src/rest-server.ts (resolveExecCtx, computeExecCtx, enforceAuth), packages/rest/src/index.ts, packages/core/src/security/assemble-execution-context.ts, packages/runtime/src/flow-start-admission.ts, packages/plugins/plugin-approvals/src/lifecycle-hooks.ts, packages/core/src/utils/import-runner.ts, packages/objectql/src/engine.ts, packages/objectql/src/validation/rule-validator.ts, packages/plugins/plugin-auth/src/admin-import-users.ts, packages/services/service-automation/src/connector-pull.ts, packages/types/src/response-envelope.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts. Rule texts read for ②: AGENTS.md, scripts/pm/clause2-line.mjs, scripts/pm/record-recognisers.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, the WHICH LEVEL prose in .github/workflows/pr-automation.yml, .claude/skills/pm-dispatch/references/contract-review.md, .changeset/pre.json, and the stock changesets 24097-rest-exposure-gate-disabled-package, 24131-rest-public-form-required, 24148-cloud-connection-setup-access as precedent. Prose (content/docs/**) read as evidence only. Inputs: card #24348 (body and every comment), PR #24379 (body, file list, net diff against main at the merge-base c4526a705), and the head's check-runs.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named right or wrong:

  1. The refusal is complete on both doors, before any write, dry run included — right. The synchronous door asks importControlOptOutRefusal(body, context) at data-action-routes.ts:293, after the stage-1 enforceApiAccess (:285) and before prepareImportRequest (:304) and runImport (:330). The job door asks at :410, before prepareImportRequest (:418) and before the sys_import_job row is created (:467). The gate never reads dryRun, so a dry run carrying an opt-out is refused (pinned). A hidden object keeps its 404 because stage 1 runs first (pinned). The new suite reads the engine after every refusal on both doors: no row and no job row (pinned).

  2. No HTTP caller reaches isSystem === true at these doors — right. The gate reads the server-derived context from resolveExecCtx, never the body; the file's own docblock (:112-:121) records that a caller-supplied context is deleted by metadata-protocol. assemble-execution-context.ts:337 sets isSystem: false for every transport principal (human, guest, agent), and rest-server.ts states at :2367 and :2407 that isSystem is never set on inbound HTTP. The only isSystem: true writes in packages/rest/src are the doors' own engine-owned sys_import_job writes (:467, :483, :592, :673) — the server's context, built after the gate, never the caller's. An anonymous context cannot reach the gate: enforceAuth denies it first. So on every shipped transport the two opt-outs are refused to every signed-in caller, administrators included, and admitted only to an in-process system principal.

  3. One reading of the flags, used by the gate and the runner — right. readImportControlFlags (import-prepare.ts:223) is the only reading of the two keys; importControlOptOutRefusal (:280) and prepareImportRequest (:393) both call it; PreparedImport carries runAutomations and treatAsHistorical (:548-:549) into runImport on the sync door (...prep.prepared, :330) and in the job worker (...prepared, :502), where the runner turns them into skipAutomations and skipStateMachine + preserveAudit (import-runner.ts:758-:766). A non-literal value reads as "no opt-out" in both places, so the gate can never admit a reading the runner then treats as an opt-out. The job row's run_automations / treat_as_historical columns record the same admitted reading.

  4. Nothing newly accepts a request — right. The only admitted path, caller.isSystem === true, was admitted before; the default (both keys omitted) and explicit runAutomations: true / treatAsHistorical: false are unchanged (pinned on both doors). The family claim in the PR body holds in the cited code: admin-import-users.ts fixes runAutomations: false server-side (:555, :741) and does not forward treatAsHistorical to runImport; connector pull fixes runAutomations: true, treatAsHistorical: false (connector-pull.ts:427-:428); the runtime dispatcher's /data domain, MCP and the CLI serve no import door (searched at the head). The import undo door (data-action-routes.ts:647) sets its skip flags server-side; its value is not caller-chosen, so it is outside this gate's family and is security: the import undo door writes with skipAutomations and skipStateMachine for any caller who may undo — what an undo owes an app's controls (detail withheld under the disclosure ruling) #24380's card — no detail of it here.

  5. The spec .describe texts say exactly what the runtime now enforces — right. runAutomations (export.zod.ts:288-:299) and treatAsHistorical (:300) now say the system principal alone may send the opt-out and that the import doors refuse every other caller, every signed-in user and administrator included, with 403 PERMISSION_DENIED before any row is read — which is the gate's position (before the body is parsed) and its answer. The promise of "a silent bulk load" to any caller is gone, and the docblock @example (:253) no longer sends runAutomations: false. CreateImportJobRequestSchema is the same schema (:439), so one text covers both doors. The dry-run boundary sentence stays true. content/docs/references/api/export.mdx is the generator's output of those texts (check:generated runs inside the green Lint & Repo Gates). Neither describe carries a tracker number.

  6. Error code and status are from the catalog and consistent under ADR-0112 — right. PERMISSION_DENIED is a StandardErrorCode member (errors.zod.ts:79) and HttpStatusErrorCodeMap[403] (:186); the ledger's FORBIDDEN is only a shadow of it and was not chosen. The refusal is written through the shared nested envelope writer sendError from @objectstack/types (response-envelope.ts:204) with details: { object, refused } — the ratchet's target dialect for data-action-routes.ts (check:route-envelope is inside the green Lint & Repo Gates); the dogfood pins read error.code from it. The refusal text names the flags, what each removes and who may send them, and carries no tracker number.

  7. The swept pins keep their subjects — right. import-integration (3 sites) and import-dryrun-parity (2) and three dogfood files drop a flag their fixtures never exercised (no automation bound); organization-update-door and comment-thread-parent-pair now assert the door's 403 and then re-run the same row without the flag, keeping the guard's per-row verdict and the pair-strip assertion; import-job-integration creates its historical job as the system principal and keeps the undo as the member's. The new suite drives the real routes over a real engine and protocol with two metadata-bound hooks (an approval gate and a closed-record freeze) and asserts behaviour only; the ablation the dev reports reddens exactly the nine refusal pins.

  8. Check-runs on the head — all green. Read at 2026-10-18T18:24Z, after the last Test Core shard completed: 45 check-runs, 37 success, 8 skipped (the opt-in and full-battery jobs — Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the sharded dogfood matrix, Packed-tarball smoke, and the second Auto Label / Check PR Size runs), none failed or cancelled. The gate-carrying set is success: Lint & Repo Gates (all check:* gates), Type Check · workspace and Type Check · consumer gates, Test Core (1/6) to (6/6), Build Core, Governed Surface Queue Guard, Check Changeset, Spec property liveness, Dogfood Regression Gate. These conclusions are the derived-gate verdicts for this record. They do not answer ②: that finding is one every gate here is blind to by construction — the LEVEL axis stands down on a no, and the ADR-0087 gate reads no breaking signal from an absent arm.

② Semver level

WRONG — the failing finding. The changesets say patch with Clause-②: no and no direction arm; the diff is a narrowing of a published accept set, and this repo's rules make that a declared breaking change.

  • The act: two REST doors stop accepting two documented request values (runAutomations: false, treatAsHistorical: true) from every HTTP caller. Two known consumers break on it — objectui's Import Wizard sends both (packages/plugin-grid/src/ImportWizard.tsx :926-:927 and :937-:938 at the pinned .objectui-sha 0707b4ae3; now 403, filed as objectui Import Wizard: the "run automations" (off) and "treat as historical" (on) options are refused 403 for every signed-in user once PR #24379 lands — the wizard must stop offering them (repo:objectui) objectui#12985) and hotcrm's dev script exits by its own check. The dev names it a narrowing in the claim and in the PR body ("Basis for the Clause-② line: a narrowing").
  • The committed carriers do not declare it. .changeset/24348-import-control-opt-out-gate.md grades @objectstack/rest patch and writes Clause-②: no with no arm; the PR body does the same. readClause2Line reads an absent arm as "no direction declared", so breakingDeclaration in check-adr-0087-registration.mjs finds none of its four signals (no major, no **BREAKING banner, no ! summary, no narrowing arm) and asks for no ADR-0087 disposition, and the LEVEL axis in check-changeset-no-major.mjs stands down on a no. Every gate is green — the exact shape the arm was created to close (clause2-line.mjs: a value-domain narrowing shipped to consumers with every gate green; "no (narrowing) — NOT a widening, but breaking. This is the whole point of the arm"). AGENTS.md § Post-Task Checklist: "(narrowing) is BREAKING", "Breaking changesets must carry their migration", and "exactly one marker" for the ADR-0087 disposition; record-recognisers.mjs keys the narrowing face on that spelling "written at the start of a line in the PR body and the changeset".
  • The claim's own Clause-②: no is right (dispatch-gates' words: a card that only narrows is Clause-②: no); the arm belongs on the PR body and the changeset, which reviewFace reads. The tier is unaffected here only because the schema face (export.zod.ts) already decides it.
  • The repo's live spelling for this exact shape: changesets 24097-rest-exposure-gate-disabled-package, 24131-rest-public-form-required and 24148-cloud-connection-setup-access all write the no (narrowing) arm at line start, a fix(pkg)!: summary and/or a **BREAKING** line, and the HTML-comment disposition adr-0087: not-required (no-migration-prescription) …; 24097 and 24148 grade minor "under the launch-window convention for breaking changes" (the tree is in pre mode, tag next, and major is refused). The WHICH LEVEL prose: a fix( that changes no public surface stays patch — this one changes the accept set.
  • Required to pass, on .changeset/24348-import-control-opt-out-gate.md: the line Clause-②: no (narrowing) at the start of its line; an HTML-comment ADR-0087 disposition marker — not-required (no-migration-prescription) fits: no stored metadata moves, the refused values arrive in a request body, and the remedy the body already states is to send the request without them; a **BREAKING** line or a fix(rest)!: summary; and @objectstack/rest graded minor per the launch-window convention and the two precedents that follow it (24131's patch is the lone outlier). The PR body carries the same Clause-②: no (narrowing) line. The migration prose is already present ("If an import now answers 403 …") and stays.
  • .changeset/24348-import-opt-out-describes.md (@objectstack/spec: patch, Clause-②: no): right as it stands — the describe texts change no shape and no default, and the spec itself refuses nothing; the narrowing's carrier is the package whose doors narrow.

③ Boundary flags

Dev flags (the report's deviations, record 6253157395 on #24348):

  • Cross-lane beyond the [PM seat] domain:spec — ⏳ vacant #6017 declaration (the treatAsHistorical describe and the docblock example): right — triage ruled the adjacent flag is decided in the same round; declaring it on [PM seat] domain:spec — ⏳ vacant #6017 is the PM's act.
  • import-runner.ts untouched: right — the gate sits at the only doors where the value is caller-chosen (①.4); in-process callers fix their own values; nothing is owed on [PM seat] domain:engine — 🟢 os-project-manager #6367.
  • File surface beyond the claim (data-action-routes.ts, system-context.mdx row 30b, state-machine.mdx, security.mdx, eight test files): each forced by a gate or by the pin sweep; right. The prose is the lane seat's at TIER_DEFAULT.
  • Zone 2 readings A1-A3: confirmed in the cited code (import-prepare.ts:296 at the merge-base; import-runner.ts:758; engine.ts:6620 skipAutomations implies skipTriggers, :5005 metadata-bound hooks skipped; assemble-execution-context.ts:337).
  • Full rest suite run at 9ee5f692e, not at the head; test:repo and the full spec suite not run locally: the head's Test Core and Type Check check-runs are the verdict for the head — see ①.8.
  • Attribution: the commits carry the model-free pair and no model identifier is in the PR body, changesets or code — confirmed on the diff.
  • The throwaway undo-door probe: not in the diff (18 files, none scratch); no detail of it belongs on GitHub and none is here.

open_questions:

out_of_scope_findings:

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: claude/issue-24348-import-automation-optout-gate
Reviewed-by: session_01SYEJqDL3NXxrJRBVqaMu9T

VERDICT: FAIL

@objectstack-fleet objectstack-fleet Bot changed the title fix(rest): the import doors honour a control opt-out only for the system principal fix(rest)!: the import doors honour a control opt-out only for the system principal Oct 18, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2103a762a64436a3e045616c474b8cdfe7828f30
Local-runs: none
Scope: packages/spec/src/api/export.zod.ts + the whole non-prose diff (narrowing face), byte-identical to the head the FAIL record 6253498392 reviewed: packages/rest/src/import-prepare.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/import-control-opt-out-gate.test.ts, packages/rest/src/import-integration.test.ts, packages/rest/src/import-dryrun-parity.test.ts, packages/rest/src/import-job-integration.test.ts, the five packages/qa/dogfood/test/*.dogfood.test.ts, .changeset/24348-import-opt-out-describes.md; and the one file that moved, .changeset/24348-import-control-opt-out-gate.md, read in full at this head. Verified with git diff --stat from the previous head and with the PR's file list (the same 18 paths; additions 521 = 517 + 4, deletions 46). Also read: the PR object (title, body, labels), the card's amended claim 6252062987, the dev's patch-round report 6253589911, and for ② the readers the gates use — scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs (marker match at :1990, category parse at :2049, CATEGORIES at :536), scripts/check-changeset-no-major.mjs (LEVEL axis arm handling at :1627-:1690 and :1808-:1811), the WHICH LEVEL prose in .github/workflows/pr-automation.yml — plus the three stock changesets named in 6253498392 as precedent. Check-runs on this head read from the API.

① Derived judgments

Every judgment of record 6253498392 stands unchanged, because the only change since that head is the rest changeset (+7/-3): no code, test, spec or prose byte moved, so each of the following holds with the same line references.

  1. The refusal is complete on both doors, before any write, dry run included — right (data-action-routes.ts:293 and :410, each after the stage-1 enforceApiAccess and before prepareImportRequest, the write and the job row; pinned on both doors).
  2. No HTTP caller reaches isSystem === true at these doors — right (the gate reads the server-derived resolveExecCtx context; assemble-execution-context.ts:337 writes isSystem: false for every transport principal; the only isSystem: true writes in packages/rest/src are the doors' own engine-owned sys_import_job writes).
  3. One reading of the flags, used by the gate and the runner — right (readImportControlFlags, import-prepare.ts:223, called at :280 and :393; carried by PreparedImport into runImport on both doors).
  4. Nothing newly accepts a request — right (the system-principal path was admitted before; defaults and explicit true/false unchanged; every other runner caller fixes its own values; the undo door is server-chosen and security: the import undo door writes with skipAutomations and skipStateMachine for any caller who may undo — what an undo owes an app's controls (detail withheld under the disclosure ruling) #24380's).
  5. The spec .describe texts say exactly what the runtime enforces — right (export.zod.ts:288-:300; @example at :253 no longer sends the opt-out; one schema covers both doors; export.mdx is its generated output).
  6. Error code and status from the catalog, consistent under ADR-0112 — right (PERMISSION_DENIED is a StandardErrorCode and HttpStatusErrorCodeMap[403]; nested envelope via sendError from @objectstack/types).
  7. The swept pins keep their subjects — right.
  8. Check-runs on this head — all green, none pending. Read at 2026-10-18T19:07Z, after the umbrella Test Core run completed: 48 check-runs, 41 success, 7 skipped (the opt-in and full-battery jobs: Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the sharded dogfood matrix, Packed-tarball smoke), none failed or cancelled. Every required context is success: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Build Core, Governed Surface Queue Guard; so are Check Changeset (both runs), Type Check · workspace and · consumer gates, Spec property liveness and Dogfood Regression Gate. These conclusions are the derived-gate verdicts for this record; Check Changeset is also the job whose step "Require an ADR-0087 disposition on a declared-breaking changeset" and LEVEL axis read the amended changeset and the PR body (②).

② Semver level

RIGHT now — the FAIL's one finding is closed. .changeset/24348-import-control-opt-out-gate.md at this head carries every carrier the repo's rules and precedents require for a narrowing of a published accept set, and the gates read it so:

  • '@objectstack/rest': minor — the launch-window convention for a breaking change (the tree is in pre mode, tag next; major refused), the level 24097-rest-exposure-gate-disabled-package and 24148-cloud-connection-setup-access ship at.
  • Line 7, column 0: Clause-②: no (narrowing) — the one spelling readClause2Line reads as value no, arm narrowing (signal 4 of breakingDeclaration). The summary line now opens fix(rest)!: (signal 3) and the body carries a **BREAKING** sentence (signal 2) that names what narrows (two request values refused from every caller but the system principal, administrators included) and both known consumers — objectui's Import Wizard (#24381) and hotcrm's dev-only demo script.
  • Exactly one HTML-comment ADR-0087 disposition marker (the gate's matchAll at :1990 finds one), category not-required (no-migration-prescription) — a member of CATEGORIES — with a reason that closes the question on facts: nothing an author writes and no stored shape is added, removed, renamed or converted; both keys stay declared on ImportRequestSchema with shape and defaults unchanged; the refused values arrive in a request body and are never stored; PERMISSION_DENIED is a standard code; the remedy is request-time and is stated in the body ("If an import now answers 403 … send it without the two opt-outs"), which is the migration text AGENTS.md asks a breaking changeset to carry.
  • The LEVEL axis in check-changeset-no-major.mjs is carried by no (narrowing) (its own docblock: a declared narrowing is BREAKING and ships minor during the window) and @objectstack/rest: minor satisfies it; check-adr-0087-registration now sees a declared-breaking changeset with its disposition. The Check Changeset and Lint & Repo Gates check-runs on this head are success.
  • The other carriers the narrowing face reads are aligned by the seat's edits, verified on the PR object and the card: the PR title opens fix(rest)!:, the PR body's line reads Clause-②: no (narrowing) at the start of its line, and the claim 6252062987 was amended in place to the same arm. The tier stays CONTRACT_REVIEW_TIER by the schema face (export.zod.ts) regardless.
  • .changeset/24348-import-opt-out-describes.md (@objectstack/spec: patch, Clause-②: no) is byte-identical to the reviewed head and right as it stands: describe text only, no shape or default moves, the spec refuses nothing.

③ Boundary flags

Unchanged from 6253498392, since the diff since that head is the changeset alone: open_questions[0] (administrators) — A stands for this PR, a new entitlement is the maintainer's decision and not a condition here; open_questions[1] (the import undo door) — escalated as #24380, server-chosen value, no detail here; objectui's wizard — #24381; createMissingOptions left ungated and the v17 registry records left as is — right.

The patch round's own flags (report 6253589911), each answered:

  • Not merged with origin/main: right — no GitHub-reported conflict, no main-caused red, no queue removal, which are the only three reasons the post-PR rule gives for merging main.
  • Gate run narrowed to the 15 changeset and text families: the head's check-runs are the verdict for the head, and every lint, type-check, build and changeset gate on it is success (see ①.8 for the two test shards).
  • clause2-line.mjs has no self-check and was exercised by import: the dev's own probe; what this record stands on is the committed spelling and the gates' CI reading of it (Check Changeset success).
  • Worktree recreated from the claim branch and removed after the report: housekeeping; nothing of it is in the diff.
  • "PR body and title left to the seat": done by the seat and verified here (title fix(rest)!:, body line Clause-②: no (narrowing)).

No new open_questions, no new out_of_scope_findings. The needs:contract-review label is left for the seat; no label is written in this act.

Implemented-by: claude/issue-24348-import-automation-optout-gate
Reviewed-by: session_01SYEJqDL3NXxrJRBVqaMu9T

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — amendment for head 2103a762a

ACCEPT carried to head 2103a762a · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T19:09Z · #24348 (P0). It amends the lane ACCEPT 6253227713, which was on head 0b764a4ab.

  1. What moved since that ACCEPT: one commit, read on the compare. It touches one file, .changeset/24348-import-control-opt-out-gate.md (+7 / −3). No code, test, spec or prose byte moved.
  2. The narrowing declaration: the FAIL record 6253498392 named it, and it is now carried. The changeset is '@objectstack/rest': minor with a fix(rest)!: summary, a line-initial Clause-②: no (narrowing), one ADR-0087 not-required (no-migration-prescription) marker and a **BREAKING** sentence that names both consumers (#24381 and the hotcrm demo script). The seat's edits match it: the PR title, the body's Clause-② line and the claim 6252062987.
  3. Contract review: CONTRACT_REVIEW_TIER record 6253934057 on head 2103a762a, VERDICT: PASS. needs:contract-review is removed in this act.
  4. Pre-checks on this head:
    • Every check-run is success or a rostered skip (check-expected-skips: 7 skipped, all in the roster).
    • check-governed-merges --pr 24379: NOT governed.
    • main's last completed full CI (5da2e8881) is success.
  5. Landing: ready plus auto-merge through the queue, now.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants