From 6eb3c9529bc454fb481582b2e2147785382deefb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:17:53 +0000 Subject: [PATCH 1/9] fix(rest): the import doors refuse a control opt-out from any caller but the system principal Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- packages/rest/src/data-action-routes.ts | 30 ++++++- packages/rest/src/import-prepare.ts | 110 ++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index e78162e654a..e68effedee2 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -66,11 +66,15 @@ import { exportContentDisposition, type ExportFieldMeta, } from './export-format.js'; -import { prepareImportRequest } from './import-prepare.js'; +import { importControlOptOutRefusal, prepareImportRequest } from './import-prepare.js'; import { readTemplateMode, templateFieldDefs } from './import-template.js'; import { refuseRepeatedQueryParams } from './query-multiplicity.js'; import { refuseUnknownQueryParams } from './query-allowlist.js'; import { handleRouteError } from './error-response.js'; +// [#24348] The shared ADR-0112 nested envelope for the import doors' control +// opt-out refusal — the flat sibling-`code` dialect the doors' older answers +// use is ratcheted down by `check:route-envelope`. +import { sendError as sendEnvelopeError } from '@objectstack/types'; import { logError } from './log.js'; import { DATA_EXPORT_PARAMS, @@ -281,6 +285,19 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: if (await this.enforceApiAccess(req, res, p, environmentId, 'import')) return; const body = req.body ?? {}; + // [#24348] Who may take the object's declared controls off this + // import (`runAutomations: false`, `treatAsHistorical: true`): + // the system principal only. Asked after the object-exposure + // gate (a hidden object keeps its 404) and before the body is + // parsed, so a refused request writes no row — dry run included. + const optOutRefusal = importControlOptOutRefusal(body, context); + if (optOutRefusal) { + sendEnvelopeError(res, optOutRefusal.status, optOutRefusal.code, optOutRefusal.message, { + details: { object: objectName, refused: optOutRefusal.refused }, + }); + return; + } + // Parse + validate the payload (shared with the async job route). // The synchronous path caps at 5k rows; larger files must use the // async import-job endpoint. @@ -387,6 +404,17 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: } if (await this.enforceApiAccess(req, res, p, environmentId, 'import')) return; + // [#24348] The synchronous door's control opt-out gate, asked at + // the same point: before the body is parsed and before a job row + // exists, so a refused request creates no job and writes no row. + const optOutRefusal = importControlOptOutRefusal(req.body ?? {}, context); + if (optOutRefusal) { + sendEnvelopeError(res, optOutRefusal.status, optOutRefusal.code, optOutRefusal.message, { + details: { object: objectName, refused: optOutRefusal.refused }, + }); + return; + } + const prep = await prepareImportRequest(req.body ?? {}, { p, objectName, environmentId, maxRows: IMPORT_JOB_MAX_ROWS, // Same round-trip i18n synonyms as the synchronous route. diff --git a/packages/rest/src/import-prepare.ts b/packages/rest/src/import-prepare.ts index bbc073b0bc4..7f349a61f36 100644 --- a/packages/rest/src/import-prepare.ts +++ b/packages/rest/src/import-prepare.ts @@ -202,6 +202,102 @@ export type PrepareImportResult = | { ok: true; prepared: PreparedImport } | { ok: false; status: number; code: string; error: string }; +/** + * [#24348] The two request flags that take an object's declared business + * controls off an import's writes, read ONCE, by the parser below and by the + * door's gate ({@link importControlOptOutRefusal}) alike, so the gate can never + * judge a different reading of the body than the one the runner is handed. + * + * - `runAutomations` defaults ON: automations always ran historically (the + * engine ignored the flag until #2922), so opting out must be explicit — + * only a literal `false` turns them off, matching platform convention + * (Salesforce runs triggers on import by default). Off, the runner writes + * with `skipAutomations`, which skips the object's metadata-bound hooks and + * its record-change flow triggers. + * - `treatAsHistorical` defaults OFF (opt-in): a normal import must still walk + * the state machine — only an explicit "historical" import skips it (#3479), + * so mid-lifecycle rows aren't rejected by `initialStates`. Unlike + * `runAutomations`, the safe default is the strict one. On, the runner + * writes with `skipStateMachine` and `preserveAudit` (#3493). + */ +export function readImportControlFlags(body: any): { runAutomations: boolean; treatAsHistorical: boolean } { + return { + runAutomations: body?.runAutomations !== false, + treatAsHistorical: body?.treatAsHistorical === true, + }; +} + +/** What each control opt-out takes off the import's writes, in the refusal's words. */ +const IMPORT_CONTROL_OPT_OUT_EFFECT = { + runAutomations: '`runAutomations: false` turns off its triggers and metadata-bound hooks', + treatAsHistorical: '`treatAsHistorical: true` skips its `state_machine` rule and keeps supplied audit and read-only values', +} as const; + +/** A control opt-out the import door refuses, by its request key. */ +export type ImportControlOptOut = keyof typeof IMPORT_CONTROL_OPT_OUT_EFFECT; + +/** The import door's answer to a control opt-out it does not honour (ADR-0112: code AND status). */ +export interface ImportControlOptOutRefusal { + status: 403; + code: 'PERMISSION_DENIED'; + message: string; + /** The opt-outs the request sent, in request-key order: the refusal's subject. */ + refused: ImportControlOptOut[]; +} + +/** + * [#24348] WHO MAY TAKE AN OBJECT'S DECLARED CONTROLS OFF AN IMPORT. Answers + * the refusal when the request sends a control opt-out (`runAutomations: + * false`, `treatAsHistorical: true`; read through + * {@link readImportControlFlags}) and `caller` is not the system principal; + * `undefined` when it sends neither, or when the system principal sends them. + * + * Each opt-out removes an app's declared business controls from every row the + * import writes: its approval flows and other record-change triggers, its + * metadata-bound hooks (a closed-record freeze, say), its `state_machine` rule + * and its read-only and audit protection. So it is honoured only for the + * caller the platform already lets past those controls: the system principal, + * `ExecutionContext.isSystem === true` — the predicate every door that starts + * an elevated self-triggered flow asks + * (`refusesElevatedSelfTriggeredStart`, `@objectstack/runtime`), and the only + * exemption the create-side read-only strip and the approval record lock + * admit. ⛔ No permission, capability or administrator standing is read here: + * none of them lets a caller past those controls anywhere else either (an + * administrator releases a locked record through the audited approval rescue, + * never by writing past it). An HTTP request is assembled with + * `isSystem: false`, so at the import doors every signed-in caller, + * administrators included, is refused; the opt-outs remain for in-process + * callers that run as the system principal. + * + * Refused LOUDLY, never ignored: a caller that asked for a silent load must + * learn it did not get one. The doors ask this before the body is parsed, so + * a refused request writes no row and creates no job — on a dry run too, + * because a preview of a request the door refuses previews nothing. The + * default (both keys omitted) and an explicit `runAutomations: true` / + * `treatAsHistorical: false` are not opt-outs and pass untouched. + */ +export function importControlOptOutRefusal(body: any, caller: unknown): ImportControlOptOutRefusal | undefined { + const { runAutomations, treatAsHistorical } = readImportControlFlags(body); + const refused: ImportControlOptOut[] = []; + if (!runAutomations) refused.push('runAutomations'); + if (treatAsHistorical) refused.push('treatAsHistorical'); + if (refused.length === 0) return undefined; + if ((caller as { isSystem?: unknown } | null | undefined)?.isSystem === true) return undefined; + const named = refused.map((k) => (k === 'runAutomations' ? '`runAutomations: false`' : '`treatAsHistorical: true`')); + const plural = refused.length > 1; + return { + status: 403, + code: 'PERMISSION_DENIED', + refused, + message: + `${named.join(' and ')} ${plural ? 'are' : 'is'} refused for this caller. Only the system principal ` + + '(an in-process caller such as a seed or migration job, never a signed-in user, administrators included) ' + + "may take an object's declared controls off an import's writes: " + + `${refused.map((k) => IMPORT_CONTROL_OPT_OUT_EFFECT[k]).join(', and ')}. ` + + `Send the request without ${plural ? 'them' : 'it'} to import with those controls in force.`, + }; +} + /** * Parse & validate a bulk-import request body into a {@link PreparedImport}. * @@ -290,15 +386,11 @@ export async function prepareImportRequest( let matchFields: string[] = Array.isArray(body?.matchFields) ? body.matchFields.filter((f: any) => typeof f === 'string' && f.length > 0) : []; - // Default ON: automations always ran historically (the engine ignored the - // flag until #2922), so opt-out must be explicit — matches platform - // convention (Salesforce runs triggers on import by default). - const runAutomations = body?.runAutomations !== false; - // Default OFF (opt-in): a normal import must still walk the state machine — - // only an explicit "historical" import skips it (#3479), so mid-lifecycle - // rows aren't rejected by `initialStates`. Unlike `runAutomations`, the safe - // default is the strict one. - const treatAsHistorical = body?.treatAsHistorical === true; + // The two control flags, read where the door's gate reads them (#24348): + // the import doors refuse either opt-out from any caller but the system + // principal BEFORE this parse (`importControlOptOutRefusal`), so a value + // that reaches the runner from them is one the gate already admitted. + const { runAutomations, treatAsHistorical } = readImportControlFlags(body); const trimWhitespace = body?.trimWhitespace !== false; const nullValues: string[] | undefined = Array.isArray(body?.nullValues) ? body.nullValues.filter((v: any) => typeof v === 'string') From 9399d8ed61f90356ef73f216cf023a6222c77ffe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:19:13 +0000 Subject: [PATCH 2/9] test(rest): pin the import doors' control opt-out gate Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .../src/import-control-opt-out-gate.test.ts | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 packages/rest/src/import-control-opt-out-gate.test.ts diff --git a/packages/rest/src/import-control-opt-out-gate.test.ts b/packages/rest/src/import-control-opt-out-gate.test.ts new file mode 100644 index 00000000000..2874b565b31 --- /dev/null +++ b/packages/rest/src/import-control-opt-out-gate.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24348] WHO MAY TAKE AN OBJECT'S DECLARED CONTROLS OFF AN IMPORT. + * + * The two import doors — `POST /data/:object/import` and + * `POST /data/:object/import/jobs` — read two request flags that remove an + * app's declared business controls from every row they write: + * `runAutomations: false` (the runner writes with `skipAutomations`: no + * metadata-bound hook, no record-change trigger) and `treatAsHistorical: true` + * (`skipStateMachine` + `preserveAudit`). Both are honoured only for the + * system principal (`ExecutionContext.isSystem`), and refused `403 + * PERMISSION_DENIED`, in the ADR-0112 envelope, to every other caller — + * before the body is parsed, so a refused request writes no row and creates + * no job. `importControlOptOutRefusal` (`import-prepare.ts`) is the one + * predicate both doors ask. + * + * The fixtures are behaviour only, over the REAL route, a REAL {@link ObjectQL} + * engine, the REAL protocol and a sqlite `:memory:` driver: + * + * - `oo_deal` carries an APPROVAL GATE: a metadata-bound `beforeInsert` hook + * that marks every new deal `approval_status: 'pending'`, the way an app's + * approval opens on a new record; + * - `oo_case` carries a CLOSED-RECORD FREEZE: a metadata-bound `beforeUpdate` + * hook that refuses any change to a case whose stored `status` is `closed`. + * + * A caller that is refused leaves both controls exactly where they were; the + * same caller without the opt-out imports with both in force; the system + * principal keeps the opt-out. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; +import { SysImportJob } from '@objectstack/platform-objects/audit'; +import { RestServer } from './rest-server'; + +function makeSqliteDriver() { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); +} + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +const DEAL = { + name: 'oo_deal', label: 'Deal', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + amount: { name: 'amount', type: 'number' as const, label: 'Amount' }, + approval_status: { name: 'approval_status', type: 'text' as const, label: 'Approval status' }, + }, +}; + +const CASE = { + name: 'oo_case', label: 'Case', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + title: { name: 'title', type: 'text' as const, label: 'Title' }, + status: { + name: 'status', type: 'select' as const, label: 'Status', + options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }], + }, + }, +}; + +/** An object whose API exposure is switched off: the door's 404 must still come first. */ +const HIDDEN = { + name: 'oo_hidden', label: 'Hidden', systemFields: false, + enable: { apiEnabled: false }, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + title: { name: 'title', type: 'text' as const, label: 'Title' }, + }, +}; + +const MEMBER = { userId: 'usr_member' }; +const SYSTEM = { userId: 'usr_system_job', isSystem: true }; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(makeSqliteDriver(), true); + await engine.init(); + for (const o of [DEAL, CASE, HIDDEN, SysImportJob]) engine.registry.registerObject(o as any); + await engine.syncSchemas(); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } + + // The app's two declared controls, bound from metadata exactly as a + // `defineStack({ hooks })` app binds them — the binding `skipAutomations` + // skips. + engine.bindHooks([ + { + name: 'oo_deal_approval_gate', + object: 'oo_deal', + events: ['beforeInsert'], + handler: async (ctx: any) => { ctx.input.data.approval_status = 'pending'; }, + }, + { + name: 'oo_case_closed_freeze', + object: 'oo_case', + events: ['beforeUpdate'], + handler: async (ctx: any) => { + if (ctx.previous?.status === 'closed') throw new Error('A closed case is frozen'); + }, + }, + ] as any, { packageId: 'oo-app' }); + + await engine.insert('oo_case', { id: 'c_closed', title: 'closed as agreed', status: 'closed' }); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + let caller: Record = MEMBER; + (rest as any).resolveExecCtx = async () => caller; + rest.registerRoutes(); + const routes = rest.getRoutes(); + const find = (method: string, path: string) => routes.find((r: any) => r.method === method && r.path === path); + const sync = find('POST', '/api/v1/data/:object/import'); + const jobs = find('POST', '/api/v1/data/:object/import/jobs'); + const progress = find('GET', '/api/v1/data/import/jobs/:jobId'); + expect(sync && jobs && progress).toBeTruthy(); + return { + engine, + as(next: Record) { caller = next; }, + async door(kind: 'sync' | 'jobs', object: string, body: any) { + const res = makeRes(); + await (kind === 'sync' ? sync : jobs).handler({ params: { object }, body } as any, res); + if (kind === 'jobs' && res._status === 201) { + for (let i = 0; i < 200; i++) { + const p = makeRes(); + await progress.handler({ params: { jobId: res._json.jobId }, query: {} } as any, p); + if (['succeeded', 'failed', 'cancelled'].includes(p._json?.status)) return { res, job: p._json }; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error('import job did not finish'); + } + return { res, job: undefined as any }; + }, + }; +} + +const DEAL_ROWS = [{ id: 'd1', name: 'Acme renewal', amount: 250000 }]; +const CASE_ROWS = [{ id: 'c_closed', title: 'reopened by import' }]; +const CASE_UPDATE = { writeMode: 'update', matchFields: ['id'] }; + +/** The refusal, asserted by its envelope: code, status and the subject it names. */ +function expectRefused(res: any, object: string, refused: string[]) { + expect(res._status).toBe(403); + expect(res._json?.success).toBe(false); + expect(res._json?.error?.code).toBe('PERMISSION_DENIED'); + expect(res._json?.error?.details).toEqual({ object, refused }); +} + +describe('[#24348] the import doors honour a control opt-out only for the system principal', () => { + let ctx: Awaited>; + beforeEach(async () => { ctx = await boot(); }); + + const jobRows = () => ctx.engine.find('sys_import_job', { context: { isSystem: true } } as any); + + describe('a signed-in caller who is not the system principal', () => { + for (const kind of ['sync', 'jobs'] as const) { + it(`${kind}: runAutomations:false on an approval-gated insert is refused 403 and writes nothing`, async () => { + const { res } = await ctx.door(kind, 'oo_deal', { format: 'json', rows: DEAL_ROWS, runAutomations: false }); + expectRefused(res, 'oo_deal', ['runAutomations']); + expect(await ctx.engine.findOne('oo_deal', { where: { id: 'd1' } })).toBeNull(); + expect(await jobRows()).toEqual([]); + }); + + it(`${kind}: runAutomations:false on an update of a frozen record is refused 403 and the record is unchanged`, async () => { + const { res } = await ctx.door(kind, 'oo_case', { format: 'json', rows: CASE_ROWS, ...CASE_UPDATE, runAutomations: false }); + expectRefused(res, 'oo_case', ['runAutomations']); + expect(await ctx.engine.findOne('oo_case', { where: { id: 'c_closed' } })) + .toMatchObject({ title: 'closed as agreed', status: 'closed' }); + expect(await jobRows()).toEqual([]); + }); + + it(`${kind}: treatAsHistorical:true is refused 403 the same way`, async () => { + const { res } = await ctx.door(kind, 'oo_case', { format: 'json', rows: CASE_ROWS, ...CASE_UPDATE, treatAsHistorical: true }); + expectRefused(res, 'oo_case', ['treatAsHistorical']); + expect(await ctx.engine.findOne('oo_case', { where: { id: 'c_closed' } })) + .toMatchObject({ title: 'closed as agreed', status: 'closed' }); + }); + + it(`${kind}: both opt-outs together are named together`, async () => { + const { res } = await ctx.door(kind, 'oo_deal', { format: 'json', rows: DEAL_ROWS, runAutomations: false, treatAsHistorical: true }); + expectRefused(res, 'oo_deal', ['runAutomations', 'treatAsHistorical']); + expect(await ctx.engine.findOne('oo_deal', { where: { id: 'd1' } })).toBeNull(); + }); + } + + it('a dry run that sends the opt-out is refused too, not previewed', async () => { + const { res } = await ctx.door('sync', 'oo_deal', { format: 'json', rows: DEAL_ROWS, dryRun: true, runAutomations: false }); + expectRefused(res, 'oo_deal', ['runAutomations']); + }); + + it('an object the API does not expose keeps its 404 ahead of the refusal', async () => { + const { res } = await ctx.door('sync', 'oo_hidden', { format: 'json', rows: [{ id: 'h1', title: 'x' }], runAutomations: false }); + expect(res._status).toBe(404); + }); + + for (const kind of ['sync', 'jobs'] as const) { + it(`${kind}: the SAME caller without the opt-out imports with both controls in force`, async () => { + const deal = await ctx.door(kind, 'oo_deal', { format: 'json', rows: DEAL_ROWS }); + const created = kind === 'sync' ? deal.res._json : deal.job; + expect(created).toMatchObject({ created: 1 }); + expect(await ctx.engine.findOne('oo_deal', { where: { id: 'd1' } })) + .toMatchObject({ name: 'Acme renewal', approval_status: 'pending' }); + + const frozen = await ctx.door(kind, 'oo_case', { format: 'json', rows: CASE_ROWS, ...CASE_UPDATE }); + const updated = kind === 'sync' ? frozen.res._json : frozen.job; + expect(updated).toMatchObject({ updated: 0 }); + expect(await ctx.engine.findOne('oo_case', { where: { id: 'c_closed' } })) + .toMatchObject({ title: 'closed as agreed', status: 'closed' }); + }); + } + + it('an explicit runAutomations:true and treatAsHistorical:false are not opt-outs', async () => { + const { res } = await ctx.door('sync', 'oo_deal', { format: 'json', rows: DEAL_ROWS, runAutomations: true, treatAsHistorical: false }); + expect(res._status ?? 200).toBe(200); + expect(res._json).toMatchObject({ created: 1 }); + expect(await ctx.engine.findOne('oo_deal', { where: { id: 'd1' } })).toMatchObject({ approval_status: 'pending' }); + }); + }); + + describe('the system principal keeps the opt-out', () => { + for (const kind of ['sync', 'jobs'] as const) { + it(`${kind}: runAutomations:false writes past both controls`, async () => { + ctx.as(SYSTEM); + const deal = await ctx.door(kind, 'oo_deal', { format: 'json', rows: DEAL_ROWS, runAutomations: false }); + expect(kind === 'sync' ? deal.res._json : deal.job).toMatchObject({ created: 1 }); + const stored = await ctx.engine.findOne('oo_deal', { where: { id: 'd1' } }); + expect(stored).toMatchObject({ name: 'Acme renewal' }); + expect(stored.approval_status ?? null).toBeNull(); + + const frozen = await ctx.door(kind, 'oo_case', { format: 'json', rows: CASE_ROWS, ...CASE_UPDATE, runAutomations: false }); + expect(kind === 'sync' ? frozen.res._json : frozen.job).toMatchObject({ updated: 1 }); + expect(await ctx.engine.findOne('oo_case', { where: { id: 'c_closed' } })) + .toMatchObject({ title: 'reopened by import' }); + }); + } + + it('treatAsHistorical:true is admitted', async () => { + ctx.as(SYSTEM); + const { res } = await ctx.door('sync', 'oo_deal', { format: 'json', rows: DEAL_ROWS, treatAsHistorical: true }); + expect(res._status ?? 200).toBe(200); + expect(res._json).toMatchObject({ created: 1 }); + }); + }); +}); From a3530efbee44bdab6a44a9bb30497cb150150f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:25:09 +0000 Subject: [PATCH 3/9] test: sweep the pins that sent an import control opt-out as a non-system caller Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- ...comment-thread-parent-pair.dogfood.test.ts | 22 ++++++++++++++----- .../import-reference-exposure.dogfood.test.ts | 6 +++-- .../import-user-field-target.dogfood.test.ts | 6 +++-- .../organization-update-door.dogfood.test.ts | 20 +++++++++++++++-- .../validation-message-locale.dogfood.test.ts | 3 ++- .../rest/src/import-dryrun-parity.test.ts | 8 +++++-- packages/rest/src/import-integration.test.ts | 14 ++++++------ .../rest/src/import-job-integration.test.ts | 9 +++++++- 8 files changed, 65 insertions(+), 23 deletions(-) diff --git a/packages/qa/dogfood/test/comment-thread-parent-pair.dogfood.test.ts b/packages/qa/dogfood/test/comment-thread-parent-pair.dogfood.test.ts index 106f14b1305..4dd9c49587c 100644 --- a/packages/qa/dogfood/test/comment-thread-parent-pair.dogfood.test.ts +++ b/packages/qa/dogfood/test/comment-thread-parent-pair.dogfood.test.ts @@ -277,17 +277,27 @@ describe('[ADR-0139 E7] the comment-thread gate leafs over the parent pair: ever expect(gatedWheres.some(hasLeaf)).toBe(true); }); - it('a member’s historical import (treatAsHistorical) naming a foreign pair stores its thread’s pair', async () => { + it('a member’s historical import (treatAsHistorical) naming a foreign pair is refused at the door; without the flag it stores its thread’s pair', async () => { const posted = await stack.apiAs(token.member, 'POST', `/data/${COMMENT}`, { thread_id: `${OBJ}:${id.mine}`, body: 'ctp import probe' }); expect(posted.status, 'fixture comment').toBeLessThan(300); const j = (await posted.json()) as any; const commentId = String(j.id ?? j.record?.id ?? j.data?.id); + const rows = [{ id: commentId, object_name: OBJ, record_id: id.other }]; + // [#24348] The import doors refuse `treatAsHistorical: true` to every caller + // but the system principal, before a row is read. The engine-side half of + // this pin (the pair re-derived under `preserveAudit`) is held in + // plugin-audit's `comment-parent-pair.integration.test.ts`. + const historical = await stack.apiAs(token.member, 'POST', `/data/${COMMENT}/import`, { + format: 'json', writeMode: 'update', matchFields: ['id'], treatAsHistorical: true, rows, + }); + expect(historical.status).toBe(403); + expect(((await historical.json()) as any).error?.code).toBe('PERMISSION_DENIED'); + const untouched = await ql.findOne(COMMENT, { where: { id: commentId }, context: { ...SYS } }); + expect({ object_name: untouched.object_name, record_id: untouched.record_id }).toEqual({ object_name: OBJ, record_id: id.mine }); + // Without the flag the member's import updates the comment it owns (the + // body carries the change), and the pair it names is the engine's to strip. const res = await stack.apiAs(token.member, 'POST', `/data/${COMMENT}/import`, { - format: 'json', - writeMode: 'update', - matchFields: ['id'], - treatAsHistorical: true, - rows: [{ id: commentId, object_name: OBJ, record_id: id.other }], + format: 'json', writeMode: 'update', matchFields: ['id'], rows: [{ ...rows[0], body: 'ctp import probe, edited' }], }); expect(res.status).toBe(200); const report: any = await res.json(); diff --git a/packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts b/packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts index ad57e83875e..fdcba0f9a48 100644 --- a/packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts +++ b/packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts @@ -69,14 +69,16 @@ describe('[#22739] an import matches a lookup cell only against a target whose d const rowsFor = (rels: readonly string[], tag: string) => rels.flatMap((rel) => [{ name: `${tag}-${rel}-m`, [rel]: MARK(rel) }, { name: `${tag}-${rel}-x`, [rel]: `IMPX-NONE-${rel}` }]); + // [#24348] No `runAutomations: false`: the fixture binds no automation, and the import + // doors refuse that opt-out to every caller but the system principal. const doors = { sync: async (who: string, rows: object[]): Promise => { - const res = await stack.apiAs(who, 'POST', '/data/sox_source/import', { format: 'json', rows, runAutomations: false }); + const res = await stack.apiAs(who, 'POST', '/data/sox_source/import', { format: 'json', rows }); expect(res.status, await res.clone().text()).toBe(200); return ((await res.json()) as { results: Row[] }).results; }, jobs: async (who: string, rows: object[]): Promise => { - const res = await stack.apiAs(who, 'POST', '/data/sox_source/import/jobs', { format: 'json', rows, runAutomations: false }); + const res = await stack.apiAs(who, 'POST', '/data/sox_source/import/jobs', { format: 'json', rows }); expect(res.status, await res.clone().text()).toBe(201); const { jobId } = (await res.json()) as { jobId: string }; for (let i = 0; i < 200; i++) { diff --git a/packages/qa/dogfood/test/import-user-field-target.dogfood.test.ts b/packages/qa/dogfood/test/import-user-field-target.dogfood.test.ts index 23156966133..ba461e12a65 100644 --- a/packages/qa/dogfood/test/import-user-field-target.dogfood.test.ts +++ b/packages/qa/dogfood/test/import-user-field-target.dogfood.test.ts @@ -73,14 +73,16 @@ describe('[#22785] a user field without reference resolves to sys_user at the im await stack?.stop?.(); }); + // [#24348] No `runAutomations: false`: the fixture binds no automation, and the import + // doors refuse that opt-out to every caller but the system principal. const doors = { sync: async (rows: object[]): Promise => { - const res = await stack.apiAs(token, 'POST', '/data/iuf_task/import', { format: 'json', rows, runAutomations: false }); + const res = await stack.apiAs(token, 'POST', '/data/iuf_task/import', { format: 'json', rows }); expect(res.status, await res.clone().text()).toBe(200); return ((await res.json()) as { results: Row[] }).results; }, jobs: async (rows: object[]): Promise => { - const res = await stack.apiAs(token, 'POST', '/data/iuf_task/import/jobs', { format: 'json', rows, runAutomations: false }); + const res = await stack.apiAs(token, 'POST', '/data/iuf_task/import/jobs', { format: 'json', rows }); expect(res.status, await res.clone().text()).toBe(201); const { jobId } = (await res.json()) as { jobId: string }; for (let i = 0; i < 200; i++) { diff --git a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts index 0d896efad4b..45ba784178b 100644 --- a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts @@ -52,7 +52,9 @@ * the same reason the PATCH is: the import runner writes each row under the * caller's context, so the D2 guard clamps every row (`timezone` lands, * `name` is stripped; a row carrying only better-auth columns is refused - * per row; `treatAsHistorical` does not elevate). Insert / upsert modes + * per row; `treatAsHistorical` does not elevate — since #24348 the door + * refuses that flag outright, `403 PERMISSION_DENIED`, to every caller but + * the system principal). Insert / upsert modes * stay 405, and the conjunct the envelope names is `create`; * - `/auth/me/permissions`, the payload the console renders its edit * affordance from: `clampManagedObjectWrites` reads `userActions.edit` for @@ -281,13 +283,27 @@ describe('#15873: sys_organization platform-owned columns through PATCH /data/sy it('import: a row carrying only better-auth columns is refused per row, PERMISSION_DENIED — treatAsHistorical does not elevate', async () => { const before = await readOrg(orgId); - const res = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { + // [#24348] `treatAsHistorical` no longer reaches the rows at all: the door + // refuses it, `403 PERMISSION_DENIED`, to every caller but the system + // principal — this administrator's session included — before a row is read. + const historical = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { format: 'json', writeMode: 'update', matchFields: ['id'], treatAsHistorical: true, rows: [{ id: orgId, name: 'Imported Name 2' }], }); + expect(historical.status).toBe(403); + expect(((await historical.json()) as any).error?.code).toBe('PERMISSION_DENIED'); + expect((await readOrg(orgId)).name).toBe(before.name); + // The guard's own verdict on the import path, unchanged: the same row, + // without the refused flag, is refused per row. + const res = await stack.apiAs(token, 'POST', '/data/sys_organization/import', { + format: 'json', + writeMode: 'update', + matchFields: ['id'], + rows: [{ id: orgId, name: 'Imported Name 2' }], + }); // The import route's contract is a per-row outcome report: the request is // answered 200 and the refusal lives on the row. This is the guard's own // verdict on the import path (measured red the moment the whitelist admits diff --git a/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts b/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts index 759f36b690e..80885436ba3 100644 --- a/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts +++ b/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts @@ -159,9 +159,10 @@ describe('objectstack verify: localized field-validation messages (#3957)', () = * the same report, so both must speak the caller's language. */ it('the import row report is localized — constraint AND cell coercion', async () => { + // [#24348] No `runAutomations: false`: the fixture binds no automation, and + // the import doors refuse that opt-out to every caller but the system principal. const r = await apiIn('zh-CN', 'POST', '/data/vml_settlement/import', { format: 'json', - runAutomations: false, rows: [ { name: '越界行', penalty_amount: -1 }, // engine constraint { name: '烂单元格', quota_hours: 'abc' }, // importer coercion diff --git a/packages/rest/src/import-dryrun-parity.test.ts b/packages/rest/src/import-dryrun-parity.test.ts index c9e126bb0ef..51b7bab6ce9 100644 --- a/packages/rest/src/import-dryrun-parity.test.ts +++ b/packages/rest/src/import-dryrun-parity.test.ts @@ -217,7 +217,11 @@ describe('import dry run == real write (#4633)', () => { it('required + no value: same verdict from both halves (was `firstMissingRequiredField`)', async () => { const rows = [{ id: 'r1', billing_address: null }]; // `name` is required - const { dry, real } = await bothWays(rows, { runAutomations: false }); + // [#24348] No `runAutomations: false` here any more: this fixture binds + // no automation, so the opt-out the retired mirror needed changed + // nothing, and the import door now refuses it to every caller but the + // system principal (`import-control-opt-out-gate.test.ts`). + const { dry, real } = await bothWays(rows); expect(dry).toMatchObject({ ok: 0, errors: 1, created: 0 }); expect(real).toMatchObject({ ok: 0, errors: 1, created: 0 }); expect(verdictOf(dry)).toMatchObject({ field: 'name', code: 'required', error: 'Name is required' }); @@ -230,7 +234,7 @@ describe('import dry run == real write (#4633)', () => { // validating, so the preview must too; reporting `required` here would be // a false alarm on a row the write happily creates. const rows = [{ id: 'r2', name: 'Stark' }]; - const { dry, real } = await bothWays(rows, { runAutomations: false }); + const { dry, real } = await bothWays(rows); expect(dry).toMatchObject({ ok: 1, errors: 0, created: 1 }); expect(real).toMatchObject({ ok: 1, errors: 0, created: 1 }); expect((await engine.findOne('crm_account', { where: { id: 'r2' } }))?.tier).toBe('standard'); diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index 8ac099968a2..57c0a636519 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -417,12 +417,12 @@ describe('import route — required-field dry-run fidelity', () => { { id: 'm1', member_name: 'Alice' }, // status missing → must fail { id: 'm2', member_name: 'Bob', status: 'active' }, // complete → ok ]; - // The pre-check only runs with automations OFF (a beforeInsert hook may - // populate a required field, so with automations on we defer to the - // engine's own validation). runAutomations defaults to true since #2922, - // so the opt-out is explicit here. + // [#24348] No `runAutomations: false` here any more: the dry run asks the + // engine's own validation whatever that flag says, this fixture binds no + // automation, and the import door now refuses the opt-out to every caller + // but the system principal (`import-control-opt-out-gate.test.ts`). // Dry run: no longer reports success for the row the insert will reject. - const dry = await imp({ format: 'json', dryRun: true, runAutomations: false, rows }); + const dry = await imp({ format: 'json', dryRun: true, rows }); expect(dry._json).toMatchObject({ dryRun: true, total: 2, ok: 1, errors: 1 }); expect(dry._json.results.find((r: any) => !r.ok)).toMatchObject({ row: 1, field: 'status', code: 'required' }); expect(await engine.findOne('member', { where: { id: 'm2' } })).toBeNull(); // dry run never writes @@ -431,7 +431,7 @@ describe('import route — required-field dry-run fidelity', () => { // instead of a raw `NOT NULL constraint failed: member.status`. The field is // named by its declared LABEL, not the API name (#3957) — `status` is // declared `label: 'Status'` in this fixture. - const real = await imp({ format: 'json', runAutomations: false, rows }); + const real = await imp({ format: 'json', rows }); expect(real._json).toMatchObject({ total: 2, ok: 1, errors: 1, created: 1 }); expect(real._json.results.find((r: any) => !r.ok)).toMatchObject({ field: 'status', code: 'required', error: 'Status is required' }); expect((await engine.findOne('member', { where: { id: 'm2' } }))?.status).toBe('active'); @@ -448,7 +448,7 @@ describe('import route — required-field dry-run fidelity', () => { }); it('flags a required text field too (not just selects); a blank cell counts as missing', async () => { - const res = await imp({ format: 'json', dryRun: true, runAutomations: false, rows: [ + const res = await imp({ format: 'json', dryRun: true, rows: [ { id: 'm4', status: 'active' }, // member_name missing { id: 'm5', member_name: ' ', status: 'active' }, // member_name blank ] }); diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index f947af08027..33cf9b98588 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -116,7 +116,7 @@ async function boot(decorateDriver?: (driver: any) => void) { const routes = rest.getRoutes(); const find = (method: string, path: string) => routes.find((r: any) => r.method === method && r.path === path); return { - engine, protocol, + engine, protocol, rest, create: find('POST', '/api/v1/data/:object/import/jobs'), progress: find('GET', '/api/v1/data/import/jobs/:jobId'), results: find('GET', '/api/v1/data/import/jobs/:jobId/results'), @@ -408,10 +408,17 @@ describe('async import job — real engine + protocol integration', () => { // Seed an existing row so the historical upsert updates it (populates the undo log). await ctx.protocol.createData({ object: 'task', data: { id: 'h_existing', title: 'orig', score: 1 } }); + // [#24348] The import doors honour `treatAsHistorical: true` for the system + // principal only, so the historical job is created by one (an in-process + // caller); the undo below is the signed-in member's, as before. + const memberCtx = (ctx.rest as any).resolveExecCtx; + (ctx.rest as any).resolveExecCtx = async () => ({ userId: 'test-user', isSystem: true }); const created = await callCreate(ctx.create, { format: 'json', writeMode: 'upsert', matchFields: ['id'], treatAsHistorical: true, rows: [{ id: 'h_existing', title: 'historical', score: 42 }], }); + (ctx.rest as any).resolveExecCtx = memberCtx; + expect(created._status).toBe(201); const jobId = created._json.jobId; const done = await waitForTerminal(ctx.progress, jobId); expect(done).toMatchObject({ status: 'succeeded', updated: 1 }); From 8ec2d1be78244f858829636fcb271adc55f17e89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:26:05 +0000 Subject: [PATCH 4/9] docs(spec): name who may send the import control opt-outs in their describes Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- packages/spec/src/api/export.zod.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/api/export.zod.ts b/packages/spec/src/api/export.zod.ts index b72bb070e42..c613940d41d 100644 --- a/packages/spec/src/api/export.zod.ts +++ b/packages/spec/src/api/export.zod.ts @@ -250,7 +250,7 @@ export type ImportMappingParsed = z.infer; * { * format: 'csv', csv: 'Name,Owner,Stage\nAcme,jane@x.com,Won', * mapping: { Name: 'name', Owner: 'owner', Stage: 'stage' }, - * writeMode: 'upsert', matchFields: ['name'], runAutomations: false, + * writeMode: 'upsert', matchFields: ['name'], * } */ export const ImportRequestSchema = lazySchema(() => z.object({ @@ -288,13 +288,16 @@ export const ImportRequestSchema = lazySchema(() => z.object({ .describe( 'Fire triggers/hooks for each imported row. ON by default, and opting out must be ' + 'explicit: automations always ran on import historically (the engine ignored this flag ' + - 'until this flag was honoured), so a caller that wants a silent bulk load sends `runAutomations: false` — ' + - 'omitting the key runs them. This matches platform convention (Salesforce fires triggers ' + - 'on import by default). One boundary: a `dryRun` preview runs NO automations whatever ' + - 'this flag says.', + 'until this flag was honoured), so omitting the key runs them. This matches platform ' + + 'convention (Salesforce fires triggers on import by default). Only the system principal (an ' + + 'in-process caller such as a seed or migration job) may send `runAutomations: false`, because ' + + 'it takes the object\'s triggers and metadata-bound hooks, its approvals and record freezes ' + + 'among them, off every row: the import doors refuse it from every other caller, every ' + + 'signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is ' + + 'read. One boundary: a `dryRun` preview runs NO automations whatever this flag says.', ), treatAsHistorical: z.boolean().default(false) - .describe('Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped.'), + .describe('Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. Only the system principal (an in-process caller such as a seed or migration job) may send `treatAsHistorical: true`: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read.'), trimWhitespace: z.boolean().default(true) .describe('Trim leading/trailing whitespace from string cells'), nullValues: z.array(z.string()).optional() From 9497e5353615aa639ef337be0a1c4cc371f05998 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:31:43 +0000 Subject: [PATCH 5/9] docs(spec): regenerate the export reference for the opt-out describes Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- content/docs/references/api/export.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 648ddcb82f4..2b879a1021f 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -54,8 +54,8 @@ const result = CreateImportJobRequestSchema.parse(data); | **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. | | **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | optional (default: `"insert"`) | insert / update / upsert semantics | | **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) | -| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until this flag was honoured), so a caller that wants a silent bulk load sends `runAutomations: false` — omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). One boundary: a `dryRun` preview runs NO automations whatever this flag says. | -| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. | +| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until this flag was honoured), so omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). Only the system principal (an in-process caller such as a seed or migration job) may send `runAutomations: false`, because it takes the object's triggers and metadata-bound hooks, its approvals and record freezes among them, off every row: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. One boundary: a `dryRun` preview runs NO automations whatever this flag says. | +| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. Only the system principal (an in-process caller such as a seed or migration job) may send `treatAsHistorical: true`: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. | | **trimWhitespace** | `boolean` | optional (default: `true`) | Trim leading/trailing whitespace from string cells | | **nullValues** | `string[]` | optional | Strings treated as null/blank (besides empty string) | | **createMissingOptions** | `boolean` | optional (default: `false`) | Keep a select / radio / multiselect cell that matches none of the field's options instead of failing the row. The cell is stored as written (trimmed; for a multi-value cell, each unmatched item), and the engine's option check admits exactly the values this import kept, on this import's writes (dry run included). The field's option list is not changed: a later write that sends the field is judged against the options again, while one that leaves the field out is not affected. A field bound to a shared picklist that did not resolve is still refused. | @@ -308,8 +308,8 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. | | **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | optional (default: `"insert"`) | insert / update / upsert semantics | | **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) | -| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until this flag was honoured), so a caller that wants a silent bulk load sends `runAutomations: false` — omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). One boundary: a `dryRun` preview runs NO automations whatever this flag says. | -| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. | +| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until this flag was honoured), so omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). Only the system principal (an in-process caller such as a seed or migration job) may send `runAutomations: false`, because it takes the object's triggers and metadata-bound hooks, its approvals and record freezes among them, off every row: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. One boundary: a `dryRun` preview runs NO automations whatever this flag says. | +| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. Only the system principal (an in-process caller such as a seed or migration job) may send `treatAsHistorical: true`: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. | | **trimWhitespace** | `boolean` | optional (default: `true`) | Trim leading/trailing whitespace from string cells | | **nullValues** | `string[]` | optional | Strings treated as null/blank (besides empty string) | | **createMissingOptions** | `boolean` | optional (default: `false`) | Keep a select / radio / multiselect cell that matches none of the field's options instead of failing the row. The cell is stored as written (trimmed; for a multi-value cell, each unmatched item), and the engine's option check admits exactly the values this import kept, on this import's writes (dry run included). The field's option list is not changed: a later write that sends the field is judged against the options again, while one that leaves the field out is not affected. A field bound to a shared picklist that did not resolve is still refused. | From 9ee5f692e7b818ecf3bfa927d5b1f8c16d627b53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:32:58 +0000 Subject: [PATCH 6/9] docs: the import control opt-outs are the system principal's; changesets Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .changeset/24348-import-control-opt-out-gate.md | 15 +++++++++++++++ .changeset/24348-import-opt-out-describes.md | 7 +++++++ content/docs/permissions/system-context.mdx | 1 + content/docs/protocol/objectql/security.mdx | 6 +++++- content/docs/protocol/objectql/state-machine.mdx | 4 ++-- 5 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .changeset/24348-import-control-opt-out-gate.md create mode 100644 .changeset/24348-import-opt-out-describes.md diff --git a/.changeset/24348-import-control-opt-out-gate.md b/.changeset/24348-import-control-opt-out-gate.md new file mode 100644 index 00000000000..c511e419e65 --- /dev/null +++ b/.changeset/24348-import-control-opt-out-gate.md @@ -0,0 +1,15 @@ +--- +'@objectstack/rest': patch +--- + +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 + +Clause-②: no + +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. + +**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. + +**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. + +**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. diff --git a/.changeset/24348-import-opt-out-describes.md b/.changeset/24348-import-opt-out-describes.md new file mode 100644 index 00000000000..24d1096158f --- /dev/null +++ b/.changeset/24348-import-opt-out-describes.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +Clause-②: no + +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. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 1c28c86fa89..d5086daba4a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -140,6 +140,7 @@ that silently does not happen. | 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` | | 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` | | 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` | +| 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` | ### 3. Sharing (`plugin-sharing`) diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index 2bff0cf8599..1c071ca2cf9 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -329,7 +329,11 @@ That asymmetry is deliberate, not an oversight: `treatAsHistorical` arrives on a (non-system) REST import request, so honouring it on create would let any caller seed the approval/status columns the create-side strip exists to protect — in a single POST. **To replay archival read-only facts on create, write from a system context** (`isSystem`), the -same trust marker rule 1 already names. +same trust marker rule 1 already names. The REST import doors now hold that line at the door +as well: they refuse `treatAsHistorical: true` from every caller but the system principal, +`403 PERMISSION_DENIED` before a row is read, so over HTTP the flag no longer reaches the +strip at all; the UPDATE-only rule still binds an in-process, non-system caller of the import +runner. A non-system create that *requests* `preserveAudit` and loses fields to the strip is not silently ignored: the server logs a `WARN` naming the object, the stripped fields, and this diff --git a/content/docs/protocol/objectql/state-machine.mdx b/content/docs/protocol/objectql/state-machine.mdx index 218c9212039..8ba76358685 100644 --- a/content/docs/protocol/objectql/state-machine.mdx +++ b/content/docs/protocol/objectql/state-machine.mdx @@ -111,9 +111,9 @@ transitions: { - 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. - 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). - **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. -- **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. +- **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. - **`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. -- **…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). +- **…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). - **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. ### Conditional transitions From c2f8b1d71510ef41c51807c3ea2f9d6a37391d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:46:04 +0000 Subject: [PATCH 7/9] test(rest): type the opt-out gate pin's route and row reads Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- packages/rest/src/import-control-opt-out-gate.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/rest/src/import-control-opt-out-gate.test.ts b/packages/rest/src/import-control-opt-out-gate.test.ts index 2874b565b31..d04c7486a63 100644 --- a/packages/rest/src/import-control-opt-out-gate.test.ts +++ b/packages/rest/src/import-control-opt-out-gate.test.ts @@ -141,11 +141,14 @@ async function boot() { (rest as any).resolveExecCtx = async () => caller; rest.registerRoutes(); const routes = rest.getRoutes(); - const find = (method: string, path: string) => routes.find((r: any) => r.method === method && r.path === path); + const find = (method: string, path: string): any => { + const route = routes.find((r: any) => r.method === method && r.path === path); + if (!route) throw new Error(`route ${method} ${path} is not registered`); + return route; + }; const sync = find('POST', '/api/v1/data/:object/import'); const jobs = find('POST', '/api/v1/data/:object/import/jobs'); const progress = find('GET', '/api/v1/data/import/jobs/:jobId'); - expect(sync && jobs && progress).toBeTruthy(); return { engine, as(next: Record) { caller = next; }, @@ -255,9 +258,9 @@ describe('[#24348] the import doors honour a control opt-out only for the system ctx.as(SYSTEM); const deal = await ctx.door(kind, 'oo_deal', { format: 'json', rows: DEAL_ROWS, runAutomations: false }); expect(kind === 'sync' ? deal.res._json : deal.job).toMatchObject({ created: 1 }); - const stored = await ctx.engine.findOne('oo_deal', { where: { id: 'd1' } }); + const stored: any = await ctx.engine.findOne('oo_deal', { where: { id: 'd1' } }); expect(stored).toMatchObject({ name: 'Acme renewal' }); - expect(stored.approval_status ?? null).toBeNull(); + expect(stored?.approval_status ?? null).toBeNull(); const frozen = await ctx.door(kind, 'oo_case', { format: 'json', rows: CASE_ROWS, ...CASE_UPDATE, runAutomations: false }); expect(kind === 'sync' ? frozen.res._json : frozen.job).toMatchObject({ updated: 1 }); From 0b764a4ab7aa704685c1eabe469241d633e7ee5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:06:23 +0000 Subject: [PATCH 8/9] test(rest): type the opt-out gate pin's job read through the engine's options Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- packages/rest/src/import-control-opt-out-gate.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rest/src/import-control-opt-out-gate.test.ts b/packages/rest/src/import-control-opt-out-gate.test.ts index d04c7486a63..3573f353c0c 100644 --- a/packages/rest/src/import-control-opt-out-gate.test.ts +++ b/packages/rest/src/import-control-opt-out-gate.test.ts @@ -185,7 +185,7 @@ describe('[#24348] the import doors honour a control opt-out only for the system let ctx: Awaited>; beforeEach(async () => { ctx = await boot(); }); - const jobRows = () => ctx.engine.find('sys_import_job', { context: { isSystem: true } } as any); + const jobRows = () => ctx.engine.find('sys_import_job', { context: { isSystem: true } }); describe('a signed-in caller who is not the system principal', () => { for (const kind of ['sync', 'jobs'] as const) { From 2103a762a64436a3e045616c474b8cdfe7828f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 18:27:17 +0000 Subject: [PATCH 9/9] chore(changeset): declare the rest import-door gate a narrowing (minor, breaking) Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .changeset/24348-import-control-opt-out-gate.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.changeset/24348-import-control-opt-out-gate.md b/.changeset/24348-import-control-opt-out-gate.md index c511e419e65..c5d1c9532a8 100644 --- a/.changeset/24348-import-control-opt-out-gate.md +++ b/.changeset/24348-import-control-opt-out-gate.md @@ -1,10 +1,14 @@ --- -'@objectstack/rest': patch +'@objectstack/rest': minor --- -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 +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 -Clause-②: no +Clause-②: no (narrowing) + + + +**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. 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.