From 330669236199476b2d3485ef5e7a6eb00c48eb81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:13:11 +0000 Subject: [PATCH 1/7] fix(plugin-approvals): the status mirror writes in the record's organisation and carries no grants of its own A named actor's mirror write now carries { isSystem, userId, tenantId } with tenantId read live from the subject record (the request's organisation for an object with none), and no positions / permissions, so a runAs:'user' hook or record-change flow it fires resolves the actor's own request envelope in that organisation. Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- .../plugin-approvals/src/approval-service.ts | 98 +++++++++++++++---- 1 file changed, 78 insertions(+), 20 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index b137eedb406..9ceb29f2c7f 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -3159,23 +3159,50 @@ export class ApprovalService implements IApprovalService { * most natural approvals automation there is, had to declare `runAs:'system'` * and take blanket elevation for a case where a perfectly good scoped identity * existed. Re-attaching the actor lets those cascades run as the deciding user - * with RLS enforced. Same shape the approval node already uses when it calls - * into this service (`approval-node.ts`). + * with RLS enforced. * * `actorId` is `null` for the genuinely machine-driven transitions (the SLA * escalation's auto-decision, the dead-run sweep). There is no human to name * there, and naming a sentinel would put a non-user in `updated_by` and in - * every downstream flow's identity. Those cascades stay user-less — a flow - * that wants to react to them still has to declare `runAs:'system'`, which is - * the honest answer rather than an oversight. - * - * Deliberately carries `userId` ONLY, not the request's org. On an - * ExecutionContext `tenantId` is a driver-scoping knob, not attribution - * (`buildDriverOptions` turns it into a tenant predicate on the update), so - * passing it would newly org-scope this write and silently no-op the mirror on - * a record whose org differs from the request's — while buying nothing: the - * automation engine back-fills the run's `tenantId` from the resolved user's - * own grants. + * every downstream flow's identity. Those cascades stay user-less, under + * {@link SYSTEM_CTX} — a flow that wants to react to them still has to + * declare `runAs:'system'`, which is the honest answer rather than an + * oversight. + * + * **A named actor's write acts in the record's organisation, and carries no + * grants of its own (#24519, ruling 6271152775).** Its context is + * {@link attributedMirrorContext}: `isSystem`, the actor's `userId`, and + * `tenantId` = the organisation of the record being written. Every consumer + * that runs as the actor then resolves the actor's own request envelope in + * that organisation, through the one resolver the request path uses + * (`resolveUserAuthzGrants`): + * - a `runAs:'user'` hook's pinned api is seeded with the actor's positions, + * permission sets and organisation peers. Positions and permission sets a + * context CARRIES are kept verbatim, so the empty arrays this write used to + * carry left such a hook on the member baseline instead of the actor's own + * grants; + * - a record-change flow receives the actor and the organisation as its + * `tenantId`, and a `runAs:'user'` run resolves the same envelope under it. + * Nothing downstream supplies a missing organisation: the automation + * engine's grants bridge answers a `tenantId` only when it was handed one, + * and an organisation-less resolution is not the actor's request in the + * record's organisation — its peers are the actor alone, and its positions + * come from every organisation the actor belongs to. + * The write itself is authorized by `isSystem` alone — the security + * middleware short-circuits before it reads any grant — so leaving the arrays + * off changes nothing about what this write may do. + * + * The organisation is the record's, for every actor: an approver drawn from + * another organisation's directory (ADR-0105 D9) included, which is the + * reading #24321 applies to a `runAs:'user'` flow's decider. On an + * ExecutionContext `tenantId` is also the driver's scoping knob + * (`buildDriverOptions`): under a walled posture it narrows this update to that + * organisation's rows (and organisation-less ones), under `single` it scopes + * nothing (ADR-0131 D11). That is why the organisation is the record's LIVE + * one, read at write time, and not the request's stamp: the two agree by + * construction (#10101 stamps the request with the subject record's + * organisation), except for a record re-parented while its approval was + * pending, which the stamp would scope this write away from. */ private async mirrorStatusField( object: string, @@ -3183,15 +3210,46 @@ export class ApprovalService implements IApprovalService { field: string, status: string, actorId: string | null, + requestOrg: string | null | undefined, ): Promise { try { - const context = actorId ? { ...SYSTEM_CTX, userId: actorId } : SYSTEM_CTX; + const context = actorId + ? await this.attributedMirrorContext(object, recordId, actorId, requestOrg) + : SYSTEM_CTX; await this.engine.update(object, { id: recordId, [field]: status }, { context }); } catch (err: any) { this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`); } } + /** + * The context a named actor's status mirror writes under (see + * {@link mirrorStatusField}): `{ isSystem: true, userId, tenantId }` and + * nothing else. The organisation is the subject record's live one, read + * through {@link recordOrgResolver} — the resolver the request row was + * stamped by — and the request's own organisation for an object that has + * none (or a record that carries none), as at the stamp. ⛔ No `positions` / + * `permissions`: a carried array is kept verbatim by every `runAs:'user'` + * consumer, so an empty one would stand in for the actor's own grants. + */ + private async attributedMirrorContext( + object: string, + recordId: string, + actorId: string, + requestOrg: string | null | undefined, + ): Promise { + const column = this.recordOrgResolver.organizationFieldFor(object); + let organization: string | null = null; + if (column) { + const rows = await this.engine.find(object, { + where: { id: recordId }, fields: ['id', column], limit: 1, context: SYSTEM_CTX, + } as any); + organization = this.recordOrgResolver.organizationOf(object, Array.isArray(rows) ? rows[0] : rows); + } + if (!organization && requestOrg != null && String(requestOrg) !== '') organization = String(requestOrg); + return { isSystem: true, userId: actorId, ...(organization ? { tenantId: organization } : {}) }; + } + /** * Re-read a business record's CURRENT state by id so approver resolution binds * to live data at node entry, not the trigger snapshot the flow froze into @@ -3673,7 +3731,7 @@ export class ApprovalService implements IApprovalService { // first-party path. await this.mirrorStatusField( input.object, input.recordId, input.config.approvalStatusField, 'pending', - row.submitter_id ?? null, + row.submitter_id ?? null, requestOrg, ); } @@ -3984,7 +4042,7 @@ export class ApprovalService implements IApprovalService { if (config.approvalStatusField) { await this.mirrorStatusField( raw.object_name, raw.record_id, config.approvalStatusField, finalStatus, - actingUserId(context), + actingUserId(context), org, ); } const fresh = await this.readBackRequest(requestId, context); @@ -4466,7 +4524,7 @@ export class ApprovalService implements IApprovalService { if (config.approvalStatusField) { await this.mirrorStatusField( raw.object_name, raw.record_id, config.approvalStatusField, 'recalled', - actingUserId(context), + actingUserId(context), org, ); } @@ -4805,7 +4863,7 @@ export class ApprovalService implements IApprovalService { if (config.approvalStatusField) { await this.mirrorStatusField( raw.object_name, raw.record_id, config.approvalStatusField, 'rejected', - actingUserId(context), + actingUserId(context), org, ); } let resumed = false; @@ -4847,7 +4905,7 @@ export class ApprovalService implements IApprovalService { if (config.approvalStatusField) { await this.mirrorStatusField( raw.object_name, raw.record_id, config.approvalStatusField, 'returned', - actingUserId(context), + actingUserId(context), org, ); } @@ -6510,7 +6568,7 @@ export class ApprovalService implements IApprovalService { // No human did this — a sweep did. Left user-less on purpose (#3783): a // flow that wants to react to a dead-run release declares runAs:'system'. await this.mirrorStatusField( - raw.object_name, raw.record_id, config.approvalStatusField, 'recalled', null, + raw.object_name, raw.record_id, config.approvalStatusField, 'recalled', null, org, ); } From 668785ffac4b61e1c16d7bb851623966dbd9325e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:17:57 +0000 Subject: [PATCH 2/7] test(plugin-approvals): pin the envelope a status mirror hands its runAs:'user' cascade, door by door Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- ...status-mirror-envelope.integration.test.ts | 391 ++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts diff --git a/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts new file mode 100644 index 00000000000..c84a0d8ba88 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts @@ -0,0 +1,391 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24519 — what an approval's status write-back hands the automation it fires. + * + * The status mirror (`ApprovalService.mirrorStatusField`) is an elevated write + * made in a person's name: the submitter submitting or recalling, an approver + * deciding or sending back. A `runAs:'user'` hook on the business object and a + * `runAs:'user'` record-change flow both run AS that person. The contract, + * ruled on the card (6271152775): what they run with is that person's own + * request envelope in the RECORD's organisation — the positions, permission + * sets and organisation peers `resolveUserAuthzGrants(user, { tenantId })` + * answers — for every door that names a person, an approver drawn from another + * organisation's directory included. + * + * Two producer facts make that true, and each has its own pins here: + * - the write names the organisation (`tenantId`), read live off the record. + * Without it every consumer resolves organisation-less: the person alone as + * peer, and positions from every organisation they belong to; + * - the write carries NO `positions` / `permissions` of its own. A consumer + * keeps carried arrays verbatim, so empty ones stood in for the person's + * grants and left the hook on the member baseline. + * + * Real kernel, real ObjectQL engine with its hook wrappers, real record-change + * trigger and automation engine, real `ApprovalService` and approval node + * executor, driver-sql on better-sqlite3 `:memory:`. The authz tables the + * resolver reads are spelled locally (the `status-mirror-cascade` precedent: + * no dependency edge onto `plugin-auth` / `plugin-security`), and the security + * catalog that names a permission set is bound with the package's test kit. + * What the security plugin then ENFORCES for a given envelope is pinned where + * the seed lives (`service-automation`'s `runas-user-hook-org-peers`); this file + * pins what the producer hands over. + * + * Fixture, all synthetic: + * - organisation A (the Default Organization) and organisation B; + * - `submitter`: member of A, and of B under a role (`probe_role_b`) held in B + * only — the person whose memberships differ between organisations; + * - `approver`: member of A, holding an A-scoped grant of a permission set + * (`probe_grant_beyond_baseline`) the baseline does not carry; + * - `peer`: member of A; `outsider`: member of B; + * - `xorg`: member of B only — an approver from another organisation. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectKernel, resolveUserAuthzGrants } from '@objectstack/core'; +import { ObjectQLPlugin, bindHooksToEngine } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; +import { APPROVAL_BRANCH_LABELS, APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation'; +import { ApprovalService } from './approval-service.js'; +import { registerApprovalNode } from './approval-node.js'; +import { SysApprovalRequest } from './sys-approval-request.object.js'; +import { SysApprovalAction } from './sys-approval-action.object.js'; +import { SysApprovalApprover } from './sys-approval-approver.object.js'; +import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { SysApprovalToken } from './sys-approval-token.object.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const ORG_A = 'org_default'; +const ORG_B = 'org_b'; +const EXTRA_SET = 'probe_grant_beyond_baseline'; +const ROLE_ONLY_IN_B = 'probe_role_b'; +const SYS = { isSystem: true } as const; +const silent = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const text = (name: string) => ({ name, type: 'text' as const }); +/** + * The columns `resolveUserAuthzGrants` reads, and nothing else. Platform-global + * here (ADR-0066): the real tables are better-auth's and carry their own + * `organization_id` as data, which is all the resolver filters on. + */ +const PLATFORM_GLOBAL = { enabled: false }; +const authzObjects = [ + { owner: '@objectstack/plugin-auth', def: { name: 'sys_user', label: 'User', tenancy: PLATFORM_GLOBAL, fields: { id: { ...text('id'), primaryKey: true }, email: text('email') } } }, + { owner: '@objectstack/plugin-auth', def: { name: 'sys_member', label: 'Member', tenancy: PLATFORM_GLOBAL, fields: { id: { ...text('id'), primaryKey: true }, user_id: text('user_id'), organization_id: text('organization_id'), role: text('role') } } }, + { owner: '@objectstack/plugin-security', def: { name: 'sys_user_position', label: 'User Position', tenancy: PLATFORM_GLOBAL, fields: { id: { ...text('id'), primaryKey: true }, user_id: text('user_id'), position: text('position'), organization_id: text('organization_id') } } }, + { owner: '@objectstack/plugin-security', def: { name: 'sys_user_permission_set', label: 'User Permission Set', tenancy: PLATFORM_GLOBAL, fields: { id: { ...text('id'), primaryKey: true }, user_id: text('user_id'), permission_set: text('permission_set'), organization_id: text('organization_id') } } }, +] as const; + +const deal = { + name: 'probe_deal', + label: 'Probe deal', + fields: { name: text('name'), approval_status: text('approval_status') }, +}; +/** Read by the hook and nothing else, so the context the read carries is the hook's own. */ +const hookSink = { name: 'probe_hook_sink', label: 'Hook sink', tenancy: { enabled: false }, fields: { name: text('name') } }; + +const FLOW_NAME = 'deal_approval'; +const NODE_ID = 'approve_step'; +/** The owning flow `sendBack` checks for its revise window. */ +const owningFlow = { + name: FLOW_NAME, + nodes: [{ id: NODE_ID, type: 'approval' }, { id: 'revise_window', type: APPROVAL_REVISE_NODE_TYPE }], + edges: [{ id: 'r', source: NODE_ID, target: 'revise_window', label: APPROVAL_BRANCH_LABELS.revise }], +}; +/** The automation an author writes against the mirrored field: runs as the person, records what it runs with. */ +const cascadeFlow = { + name: 'probe_mirror_cascade', + label: 'Probe mirror cascade', + type: 'record_change', + runAs: 'user', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'probe_deal', triggerType: 'record-after-update' } }, + { id: 'capture', type: 'probe_capture', label: 'Capture' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'capture' }, { id: 'e2', source: 'capture', target: 'end' }], +}; + +/** A door's own context: the person, as the REST layer hands it (its grants are not what the mirror reads). */ +const asPerson = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; + +type Envelope = { tenantId: unknown; positions: unknown; permissions: unknown; org_user_ids: unknown }; +const sorted = (v: unknown) => (Array.isArray(v) ? [...v].map(String).sort() : 'ABSENT'); +const envelopeOf = (c: any): Envelope => ({ + tenantId: c?.tenantId, + positions: sorted(c?.positions), + permissions: sorted(c?.permissions), + org_user_ids: sorted(c?.org_user_ids), +}); + +describe("the status mirror hands a runAs:'user' cascade the person's own request envelope in the record's organisation (#24519)", () => { + let kernel: ObjectKernel; + let ql: any; + let svc: ApprovalService; + let openNode: (recordId: string, submitter: string, approvers: string[], config?: Record) => Promise; + /** The context of every write to the business object, in order. */ + let writes: any[]; + /** The context the `runAs:'user'` hook's read carried, per firing. */ + let hookSeen: any[]; + /** A refusal the hook's read met, per firing that met one. */ + let hookRefusals: string[]; + /** The context the `runAs:'user'` record-change flow ran with, per run. */ + let flowSeen: any[]; + + afterEach(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + beforeEach(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin()); + await kernel.use(new RecordChangeTriggerPlugin()); + await kernel.bootstrap(); + ql = kernel.getService('objectql'); + const automation = kernel.getService('automation'); + + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + for (const def of [deal, hookSink, SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalDelegation, SysApprovalToken]) { + ql.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); + } + for (const o of authzObjects) ql.registry.registerObject(o.def as any, o.owner); + ql.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: text('name'), slug: text('slug') } } as any, + '@objectstack/plugin-auth', + ); + await ql.syncSchemas(); + bindTestSecurityCatalog(ql, { permissions: [{ name: EXTRA_SET }] }); + + await ql.insert('sys_organization', { id: ORG_A, name: 'Default Organization', slug: 'default' }, { context: SYS }); + await ql.insert('sys_organization', { id: ORG_B, name: 'Organisation B', slug: 'org-b' }, { context: SYS }); + for (const id of ['submitter', 'approver', 'peer', 'outsider', 'xorg']) { + await ql.insert('sys_user', { id, email: `${id}@example.test` }, { context: SYS }); + } + const members: Array<[string, string, string]> = [ + ['submitter', ORG_A, 'member'], ['submitter', ORG_B, ROLE_ONLY_IN_B], + ['approver', ORG_A, 'member'], ['peer', ORG_A, 'member'], + ['outsider', ORG_B, 'member'], ['xorg', ORG_B, 'member'], + ]; + for (const [userId, org, role] of members) { + await ql.insert('sys_member', { id: `mem_${userId}_${org}`, user_id: userId, organization_id: org, role }, { context: SYS }); + } + await ql.insert('sys_user_permission_set', { + id: 'ups_approver_extra', user_id: 'approver', permission_set: EXTRA_SET, organization_id: ORG_A, + }, { context: SYS }); + + writes = []; + hookSeen = []; + hookRefusals = []; + flowSeen = []; + ql.registerMiddleware(async (opCtx: any, next: any) => { + if (opCtx.object === 'probe_hook_sink') hookSeen.push({ ...(opCtx.context ?? {}) }); + if (opCtx.object === 'probe_deal' && opCtx.operation === 'update') writes.push({ ...(opCtx.context ?? {}) }); + return next(); + }); + bindHooksToEngine(ql, [{ + name: 'probe_reads_as_the_person', + object: 'probe_deal', + events: ['afterUpdate'], + priority: 100, + runAs: 'user', + handler: async (ctx: any) => { + try { + await ctx.api.object('probe_hook_sink').find({ fields: ['id'] }); + } catch (e: any) { + hookRefusals.push(String(e?.code ?? e?.message ?? e)); + } + }, + } as any], { packageId: 'app:test', logger: silent as any }); + + automation.registerNodeExecutor({ + type: 'probe_capture', + async execute(_node: any, _vars: any, context: any) { + flowSeen.push({ ...(context ?? {}) }); + return { success: true }; + }, + } as any); + automation.registerFlow(cascadeFlow.name, cascadeFlow as any); + + svc = new ApprovalService({ + engine: ql, + automation: { + resume: async () => ({ status: 'completed' }), + getRun: async (runId: string) => ({ id: runId, status: 'suspended' }), + hasSuspendedRun: async () => true, + cancelRun: async () => undefined, + getFlow: async (name: string) => (name === FLOW_NAME ? owningFlow : undefined), + } as any, + logger: silent as any, + }); + let executor: any; + registerApprovalNode({ registerNodeExecutor: (e: any) => { if (e.type === 'approval') executor = e; } } as any, svc, silent); + + /** Open a request through the REAL approval node, as a `runAs:'user'` approval flow acting in A runs it. */ + openNode = async (recordId, submitter, approvers, config = {}) => { + const vars = new Map([ + ['$runId', `run_${recordId}`], ['$flowName', FLOW_NAME], ['$record', { id: recordId, name: recordId }], + ]); + const out = await executor.execute( + { + id: NODE_ID, type: 'approval', + config: { approvers: approvers.map((value) => ({ type: 'user', value })), behavior: 'first_response', lockRecord: false, approvalStatusField: 'approval_status', ...config }, + }, + vars, + { userId: submitter, tenantId: ORG_A, object: 'probe_deal' }, + ); + expect(out.success, JSON.stringify(out)).toBe(true); + return String(out.correlation); + }; + }); + + async function seedDeal(id: string, org = ORG_A) { + await ql.insert('probe_deal', { id, name: id, organization_id: org }, { context: { isSystem: true, tenantId: org } }); + } + + /** What the person's own request acting in `org` carries: the request path's resolver, by construction. */ + async function requestEnvelope(userId: string, org: string): Promise { + const g = await resolveUserAuthzGrants(ql, userId, { tenantId: org, bypassGrantsCache: true }); + return envelopeOf({ tenantId: org, positions: g.positions, permissions: g.permissions, org_user_ids: g.org_user_ids }); + } + + /** Wait for the cascade a mirror write fired: the hook runs in the write, the flow after it. */ + async function cascade(before: { hooks: number; flows: number }) { + for (let i = 0; i < 150 && flowSeen.length <= before.flows; i++) await sleep(20); + expect(hookSeen.length + hookRefusals.length, 'the hook ran exactly once').toBe(before.hooks + 1); + expect(flowSeen.length, 'the record-change flow ran exactly once').toBe(before.flows + 1); + return { write: writes[writes.length - 1], hook: hookSeen[hookSeen.length - 1], flow: flowSeen[flowSeen.length - 1] }; + } + const mark = () => ({ hooks: hookSeen.length + hookRefusals.length, flows: flowSeen.length }); + + /** The producer half: elevated, attributed, the record's organisation, no grants of its own. */ + function expectAttributedWrite(write: any, userId: string, org: string) { + expect(write.isSystem).toBe(true); + expect(write.userId).toBe(userId); + expect(write.tenantId).toBe(org); + expect('positions' in write, 'the write carries no positions of its own').toBe(false); + expect('permissions' in write, 'the write carries no permission sets of its own').toBe(false); + } + + async function expectPersonInOrg(seen: { write: any; hook: any; flow: any }, userId: string, org: string) { + expectAttributedWrite(seen.write, userId, org); + const expected = await requestEnvelope(userId, org); + expect(seen.hook.userId).toBe(userId); + expect(seen.hook.isSystem).toBe(false); + expect(envelopeOf(seen.hook), "the runAs:'user' hook").toEqual(expected); + expect(seen.flow.userId).toBe(userId); + expect(envelopeOf(seen.flow), "the runAs:'user' record-change flow").toEqual(expected); + return expected; + } + + it('open, through the approval node: the submitter in the record organisation, and no role held only in another organisation', async () => { + await seedDeal('d_open'); + const before = mark(); + const requestId = await openNode('d_open', 'submitter', ['approver']); + const [request] = await ql.find('sys_approval_request', { where: { id: requestId }, context: SYS }); + expect(request.organization_id, 'fixture premise: the request is stamped with the record organisation').toBe(ORG_A); + const expected = await expectPersonInOrg(await cascade(before), 'submitter', ORG_A); + // The person whose memberships differ: what A grants, never what B does. + expect(expected.positions).not.toContain(ROLE_ONLY_IN_B); + expect(expected.org_user_ids).toEqual(['approver', 'peer', 'submitter']); + }); + + it('decide approve: the approver carries the grant they hold beyond the baseline, and their organisation peers', async () => { + await seedDeal('d_approve'); + const requestId = await openNode('d_approve', 'submitter', ['approver']); + const before = mark(); + await svc.decide(requestId, { decision: 'approve', actorId: 'approver' } as any, asPerson('approver')); + const expected = await expectPersonInOrg(await cascade(before), 'approver', ORG_A); + expect(expected.permissions).toContain(EXTRA_SET); + expect(expected.org_user_ids).toEqual(['approver', 'peer', 'submitter']); + }); + + it('decide reject: the approver, in the record organisation', async () => { + await seedDeal('d_reject'); + const requestId = await openNode('d_reject', 'submitter', ['approver']); + const before = mark(); + await svc.decide(requestId, { decision: 'reject', actorId: 'approver' } as any, asPerson('approver')); + const expected = await expectPersonInOrg(await cascade(before), 'approver', ORG_A); + expect(expected.permissions).toContain(EXTRA_SET); + }); + + it('recall: the submitter, in the record organisation', async () => { + await seedDeal('d_recall'); + const requestId = await openNode('d_recall', 'submitter', ['approver']); + const before = mark(); + await svc.recall(requestId, { actorId: 'submitter' } as any, asPerson('submitter')); + const expected = await expectPersonInOrg(await cascade(before), 'submitter', ORG_A); + expect(expected.positions).not.toContain(ROLE_ONLY_IN_B); + }); + + it('send back for revision: the approver, in the record organisation', async () => { + await seedDeal('d_returned'); + const requestId = await openNode('d_returned', 'submitter', ['approver']); + const before = mark(); + await svc.sendBack(requestId, { actorId: 'approver' } as any, asPerson('approver')); + const seen = await cascade(before); + expect((await ql.find('probe_deal', { where: { id: 'd_returned' }, context: SYS }))[0].approval_status).toBe('returned'); + await expectPersonInOrg(seen, 'approver', ORG_A); + }); + + it('send back past the revision limit (auto-reject): the approver, in the record organisation', async () => { + await seedDeal('d_autoreject'); + const requestId = await openNode('d_autoreject', 'submitter', ['approver'], { maxRevisions: 0 }); + const before = mark(); + await svc.sendBack(requestId, { actorId: 'approver' } as any, asPerson('approver')); + const seen = await cascade(before); + expect((await ql.find('probe_deal', { where: { id: 'd_autoreject' }, context: SYS }))[0].approval_status).toBe('rejected'); + await expectPersonInOrg(seen, 'approver', ORG_A); + }); + + it('the action link: the approver the token binds, in the record organisation', async () => { + await seedDeal('d_link'); + const requestId = await openNode('d_link', 'submitter', ['approver']); + const { approve } = await svc.issueActionTokens(requestId, 'approver'); + const before = mark(); + expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: true, action: 'approve' }); + await expectPersonInOrg(await cascade(before), 'approver', ORG_A); + }); + + it("an approver from another organisation's directory acts in the record's organisation", async () => { + await seedDeal('d_xorg'); + const requestId = await openNode('d_xorg', 'submitter', ['xorg']); + const before = mark(); + await svc.decide(requestId, { decision: 'approve', actorId: 'xorg' } as any, asPerson('xorg')); + const expected = await expectPersonInOrg(await cascade(before), 'xorg', ORG_A); + // The record organisation's members (and the approver), never their own organisation's. + expect(expected.org_user_ids).toEqual(['approver', 'peer', 'submitter', 'xorg']); + }); + + it("the organisation is the record's live one: a record re-parented while its request was pending", async () => { + await seedDeal('d_moved'); + const requestId = await openNode('d_moved', 'submitter', ['approver']); + await ql.update('probe_deal', { id: 'd_moved', organization_id: ORG_B }, { context: SYS }); + const before = mark(); + await svc.decide(requestId, { decision: 'approve', actorId: 'approver' } as any, asPerson('approver')); + await expectPersonInOrg(await cascade(before), 'approver', ORG_B); + }); + + it('CONTROL — a release nobody decided (the dead-run sweep) stays user-less: the hook is refused and the flow has no person', async () => { + await seedDeal('d_sweep'); + await openNode('d_sweep', 'submitter', ['approver']); + svc.attachAutomation({ getRun: async () => ({ status: 'failed' }) } as any); + const reads = hookSeen.length; + const flows = flowSeen.length; + await svc.releaseDeadRunRequests(); + for (let i = 0; i < 150 && flowSeen.length <= flows; i++) await sleep(20); + expect((await ql.find('probe_deal', { where: { id: 'd_sweep' }, context: SYS }))[0].approval_status).toBe('recalled'); + const write = writes[writes.length - 1]; + expect(write.isSystem).toBe(true); + expect(write.userId).toBeUndefined(); + expect(hookSeen.length, "a user-less write gives the runAs:'user' hook nobody to read as").toBe(reads); + expect(hookRefusals).toEqual([expect.stringContaining('HOOK_UNSCOPED_DATA_ACCESS')]); + expect(flowSeen.length).toBe(flows + 1); + expect(flowSeen[flowSeen.length - 1].userId).toBeUndefined(); + }); +}); From cd8f5553ce38478e8c111497902129e4cf8838d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:21:48 +0000 Subject: [PATCH 3/7] test(plugin-approvals): assert the consumers' envelopes before the producer's write shape Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- ...status-mirror-envelope.integration.test.ts | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts index c84a0d8ba88..e035f45be2e 100644 --- a/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts @@ -272,15 +272,16 @@ describe("the status mirror hands a runAs:'user' cascade the person's own reques expect('permissions' in write, 'the write carries no permission sets of its own').toBe(false); } + /** The consumers first (what an author's automation sees), then the producer fact that explains it. */ async function expectPersonInOrg(seen: { write: any; hook: any; flow: any }, userId: string, org: string) { - expectAttributedWrite(seen.write, userId, org); const expected = await requestEnvelope(userId, org); expect(seen.hook.userId).toBe(userId); expect(seen.hook.isSystem).toBe(false); expect(envelopeOf(seen.hook), "the runAs:'user' hook").toEqual(expected); expect(seen.flow.userId).toBe(userId); expect(envelopeOf(seen.flow), "the runAs:'user' record-change flow").toEqual(expected); - return expected; + expectAttributedWrite(seen.write, userId, org); + return seen; } it('open, through the approval node: the submitter in the record organisation, and no role held only in another organisation', async () => { @@ -289,10 +290,11 @@ describe("the status mirror hands a runAs:'user' cascade the person's own reques const requestId = await openNode('d_open', 'submitter', ['approver']); const [request] = await ql.find('sys_approval_request', { where: { id: requestId }, context: SYS }); expect(request.organization_id, 'fixture premise: the request is stamped with the record organisation').toBe(ORG_A); - const expected = await expectPersonInOrg(await cascade(before), 'submitter', ORG_A); + const { hook, flow } = await expectPersonInOrg(await cascade(before), 'submitter', ORG_A); // The person whose memberships differ: what A grants, never what B does. - expect(expected.positions).not.toContain(ROLE_ONLY_IN_B); - expect(expected.org_user_ids).toEqual(['approver', 'peer', 'submitter']); + expect(hook.positions).not.toContain(ROLE_ONLY_IN_B); + expect(flow.positions).not.toContain(ROLE_ONLY_IN_B); + expect(sorted(hook.org_user_ids)).toEqual(['approver', 'peer', 'submitter']); }); it('decide approve: the approver carries the grant they hold beyond the baseline, and their organisation peers', async () => { @@ -300,9 +302,11 @@ describe("the status mirror hands a runAs:'user' cascade the person's own reques const requestId = await openNode('d_approve', 'submitter', ['approver']); const before = mark(); await svc.decide(requestId, { decision: 'approve', actorId: 'approver' } as any, asPerson('approver')); - const expected = await expectPersonInOrg(await cascade(before), 'approver', ORG_A); - expect(expected.permissions).toContain(EXTRA_SET); - expect(expected.org_user_ids).toEqual(['approver', 'peer', 'submitter']); + const { hook, flow } = await expectPersonInOrg(await cascade(before), 'approver', ORG_A); + // The grant beyond the baseline is the approver's, so the automation that runs as them holds it. + expect(hook.permissions).toContain(EXTRA_SET); + expect(flow.permissions).toContain(EXTRA_SET); + expect(sorted(hook.org_user_ids)).toEqual(['approver', 'peer', 'submitter']); }); it('decide reject: the approver, in the record organisation', async () => { @@ -310,8 +314,8 @@ describe("the status mirror hands a runAs:'user' cascade the person's own reques const requestId = await openNode('d_reject', 'submitter', ['approver']); const before = mark(); await svc.decide(requestId, { decision: 'reject', actorId: 'approver' } as any, asPerson('approver')); - const expected = await expectPersonInOrg(await cascade(before), 'approver', ORG_A); - expect(expected.permissions).toContain(EXTRA_SET); + const { hook } = await expectPersonInOrg(await cascade(before), 'approver', ORG_A); + expect(hook.permissions).toContain(EXTRA_SET); }); it('recall: the submitter, in the record organisation', async () => { @@ -319,8 +323,9 @@ describe("the status mirror hands a runAs:'user' cascade the person's own reques const requestId = await openNode('d_recall', 'submitter', ['approver']); const before = mark(); await svc.recall(requestId, { actorId: 'submitter' } as any, asPerson('submitter')); - const expected = await expectPersonInOrg(await cascade(before), 'submitter', ORG_A); - expect(expected.positions).not.toContain(ROLE_ONLY_IN_B); + const { hook, flow } = await expectPersonInOrg(await cascade(before), 'submitter', ORG_A); + expect(hook.positions).not.toContain(ROLE_ONLY_IN_B); + expect(flow.positions).not.toContain(ROLE_ONLY_IN_B); }); it('send back for revision: the approver, in the record organisation', async () => { @@ -357,9 +362,10 @@ describe("the status mirror hands a runAs:'user' cascade the person's own reques const requestId = await openNode('d_xorg', 'submitter', ['xorg']); const before = mark(); await svc.decide(requestId, { decision: 'approve', actorId: 'xorg' } as any, asPerson('xorg')); - const expected = await expectPersonInOrg(await cascade(before), 'xorg', ORG_A); + const { hook, flow } = await expectPersonInOrg(await cascade(before), 'xorg', ORG_A); // The record organisation's members (and the approver), never their own organisation's. - expect(expected.org_user_ids).toEqual(['approver', 'peer', 'submitter', 'xorg']); + expect(sorted(hook.org_user_ids)).toEqual(['approver', 'peer', 'submitter', 'xorg']); + expect(sorted(flow.org_user_ids)).toEqual(['approver', 'peer', 'submitter', 'xorg']); }); it("the organisation is the record's live one: a record re-parented while its request was pending", async () => { From 8f78b065403101bfc17224764fa8de28ecae05b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:23:50 +0000 Subject: [PATCH 4/7] chore(changeset): plugin-approvals status write-back runs its automation as the actor in the record's organisation Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- .changeset/24519-approvals-context-identity.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/24519-approvals-context-identity.md diff --git a/.changeset/24519-approvals-context-identity.md b/.changeset/24519-approvals-context-identity.md new file mode 100644 index 00000000000..c9ded0adfff --- /dev/null +++ b/.changeset/24519-approvals-context-identity.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +fix(plugin-approvals): an approval's status write-back runs its automation as the person who acted, in the record's organisation + +Clause-②: no + +- **What changes.** The write that mirrors a request's status onto the record's `approvalStatusField` (submit, approve, reject, recall, send back, the action link) now names the organisation of the record it writes, and no longer carries an empty set of positions and permission sets. A `runAs: 'user'` hook on that object, and a `runAs: 'user'` record-change flow fired by that write, now run with the acting person's own request envelope in the record's organisation: their positions, their permission sets and their organisation's members, resolved exactly as their own request in that organisation resolves them. +- **Before.** Such a hook ran on the member baseline and saw only the acting person in `sys_user`. Such a flow resolved the person's grants with no organisation: it saw only the person as a peer, and its positions came from every organisation the person belongs to rather than from the record's. +- **Every acting person, one organisation.** An approver drawn from another organisation's directory also acts in the record's organisation. The organisation is read from the record when the status is written, so a record moved to another organisation while its approval was pending is written, and cascades, in its new organisation. +- **Unchanged.** The write stays elevated (`isSystem`), so it still lands on a locked record and on a read-only status field. A status written by a sweep with no person behind it (the dead-run release) stays user-less, so a `runAs: 'user'` automation it fires is still refused. `ctx.session.organizationId` in a hook on the write now carries the organisation, and `ctx.session.positions` is absent instead of an empty list. + +A patch: no export, type, accepted value or stored column changes. From 2e76c1b42ed8683650149f3a3cd2a2203530835f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:24:11 +0000 Subject: [PATCH 5/7] docs(plugin-approvals): describe the organisation-less envelope by its effect Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- .changeset/24519-approvals-context-identity.md | 2 +- packages/plugins/plugin-approvals/src/approval-service.ts | 4 ++-- .../src/status-mirror-envelope.integration.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/24519-approvals-context-identity.md b/.changeset/24519-approvals-context-identity.md index c9ded0adfff..b002044f2ea 100644 --- a/.changeset/24519-approvals-context-identity.md +++ b/.changeset/24519-approvals-context-identity.md @@ -7,7 +7,7 @@ fix(plugin-approvals): an approval's status write-back runs its automation as th Clause-②: no - **What changes.** The write that mirrors a request's status onto the record's `approvalStatusField` (submit, approve, reject, recall, send back, the action link) now names the organisation of the record it writes, and no longer carries an empty set of positions and permission sets. A `runAs: 'user'` hook on that object, and a `runAs: 'user'` record-change flow fired by that write, now run with the acting person's own request envelope in the record's organisation: their positions, their permission sets and their organisation's members, resolved exactly as their own request in that organisation resolves them. -- **Before.** Such a hook ran on the member baseline and saw only the acting person in `sys_user`. Such a flow resolved the person's grants with no organisation: it saw only the person as a peer, and its positions came from every organisation the person belongs to rather than from the record's. +- **Before.** Such a hook ran on the member baseline and saw only the acting person in `sys_user`. Such a flow resolved the person's grants with no organisation, so it saw only the person as a peer and did not run with what the record's organisation grants them. - **Every acting person, one organisation.** An approver drawn from another organisation's directory also acts in the record's organisation. The organisation is read from the record when the status is written, so a record moved to another organisation while its approval was pending is written, and cascades, in its new organisation. - **Unchanged.** The write stays elevated (`isSystem`), so it still lands on a locked record and on a read-only status field. A status written by a sweep with no person behind it (the dead-run release) stays user-less, so a `runAs: 'user'` automation it fires is still refused. `ctx.session.organizationId` in a hook on the write now carries the organisation, and `ctx.session.positions` is absent instead of an empty list. diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 9ceb29f2c7f..88082cab567 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -3186,8 +3186,8 @@ export class ApprovalService implements IApprovalService { * Nothing downstream supplies a missing organisation: the automation * engine's grants bridge answers a `tenantId` only when it was handed one, * and an organisation-less resolution is not the actor's request in the - * record's organisation — its peers are the actor alone, and its positions - * come from every organisation the actor belongs to. + * record's organisation — its peers are the actor alone, and its grants + * are not the ones that organisation gives them. * The write itself is authorized by `isSystem` alone — the security * middleware short-circuits before it reads any grant — so leaving the arrays * off changes nothing about what this write may do. diff --git a/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts index e035f45be2e..6d5d3e5a7dd 100644 --- a/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts @@ -16,7 +16,7 @@ * Two producer facts make that true, and each has its own pins here: * - the write names the organisation (`tenantId`), read live off the record. * Without it every consumer resolves organisation-less: the person alone as - * peer, and positions from every organisation they belong to; + * peer, and grants that are not the ones the record's organisation gives; * - the write carries NO `positions` / `permissions` of its own. A consumer * keeps carried arrays verbatim, so empty ones stood in for the person's * grants and left the hook on the member baseline. From c737d229a516c988575a608a2002e7c679db0950 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:42:56 +0000 Subject: [PATCH 6/7] test(plugin-approvals): the mirror identity pin asserts the ruled organisation, not its absence Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- .../src/approval-service.test.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 34256666979..dead8208815 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -5119,13 +5119,20 @@ describe('status mirror identity (#3783)', () => { expect(mirrorContext()).toMatchObject({ isSystem: true }); }); - it('carries the actor WITHOUT org-scoping the write', async () => { - // `tenantId` on an ExecutionContext is a driver-scoping knob, not - // attribution: ObjectQL turns it into a tenant predicate on the update. The - // submitter's org (`t1` on CTX) must therefore not ride along, or the mirror - // would silently no-op on a record whose org differs from the request's. - await open(); - expect(mirrorContext()).not.toHaveProperty('tenantId'); - expect(mirrorContext()).not.toHaveProperty('organizationId'); + it('names the organisation it acts in, and carries no grants of its own (#24519)', async () => { + // Ruling 6271152775 on #24519 reverses what this pin used to assert: a + // named actor's mirror names its organisation through `tenantId`, so a + // `runAs:'user'` consumer resolves the actor's own request envelope in + // it. The organisation is the subject record's, read live; this fake + // engine exposes no schema, so the object has none of its own and the + // request's organisation stands in (`t1`, from the opening context), as + // it does for the request row's stamp. No `positions` / `permissions` + // ride along — a consumer would keep them verbatim — and no + // `organizationId`, which is not an envelope field. The record-organisation + // read and the end-to-end envelope are pinned in + // `status-mirror-envelope.integration.test.ts`. + const req = await open(); + expect(req.organization_id).toBe('t1'); + expect(mirrorContext()).toEqual({ isSystem: true, userId: 'u1', tenantId: 't1' }); }); }); From 31100d065dfb949da2e8f83ef932be5059da3967 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:20:46 +0000 Subject: [PATCH 7/7] fix(plugin-approvals): type the mirror's record read and the pin's engine lookup Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- .../plugins/plugin-approvals/src/approval-service.test.ts | 4 ++-- packages/plugins/plugin-approvals/src/approval-service.ts | 4 ++-- .../src/status-mirror-envelope.integration.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index dead8208815..b4671c0aef1 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -5131,8 +5131,8 @@ describe('status mirror identity (#3783)', () => { // `organizationId`, which is not an envelope field. The record-organisation // read and the end-to-end envelope are pinned in // `status-mirror-envelope.integration.test.ts`. - const req = await open(); - expect(req.organization_id).toBe('t1'); + await open(); + expect(engine._tables['sys_approval_request'][0].organization_id).toBe('t1'); expect(mirrorContext()).toEqual({ isSystem: true, userId: 'u1', tenantId: 't1' }); }); }); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 88082cab567..83df681ebf4 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -3243,8 +3243,8 @@ export class ApprovalService implements IApprovalService { if (column) { const rows = await this.engine.find(object, { where: { id: recordId }, fields: ['id', column], limit: 1, context: SYSTEM_CTX, - } as any); - organization = this.recordOrgResolver.organizationOf(object, Array.isArray(rows) ? rows[0] : rows); + }); + organization = this.recordOrgResolver.organizationOf(object, Array.isArray(rows) ? rows[0] : undefined); } if (!organization && requestOrg != null && String(requestOrg) !== '') organization = String(requestOrg); return { isSystem: true, userId: actorId, ...(organization ? { tenantId: organization } : {}) }; diff --git a/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts index 6d5d3e5a7dd..7783e3046e4 100644 --- a/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts @@ -43,7 +43,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectKernel, resolveUserAuthzGrants } from '@objectstack/core'; -import { ObjectQLPlugin, bindHooksToEngine } from '@objectstack/objectql'; +import { ObjectQLPlugin, bindHooksToEngine, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; @@ -123,7 +123,7 @@ const envelopeOf = (c: any): Envelope => ({ describe("the status mirror hands a runAs:'user' cascade the person's own request envelope in the record's organisation (#24519)", () => { let kernel: ObjectKernel; - let ql: any; + let ql: ObjectQL; let svc: ApprovalService; let openNode: (recordId: string, submitter: string, approvers: string[], config?: Record) => Promise; /** The context of every write to the business object, in order. */ @@ -145,7 +145,7 @@ describe("the status mirror hands a runAs:'user' cascade the person's own reques await kernel.use(new AutomationServicePlugin()); await kernel.use(new RecordChangeTriggerPlugin()); await kernel.bootstrap(); - ql = kernel.getService('objectql'); + ql = kernel.getService('objectql'); const automation = kernel.getService('automation'); const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });