From d48c84b3f125ab9f594c81849811b166f6394538 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:02:35 +0000 Subject: [PATCH 01/10] feat(automation): a resumed approval run continues as its decider On resume after an approval decision (approve or reject), a runAs:'user' run's nodes after the decision run with the decider's identity and permissions, re-resolved through the run setup's grants resolver; the trigger user is kept on the run's history. plugin-approvals hands the decider to the engine on the service-stamped resume signal and journals it for a repair replay. runAs:'system' runs and signals with no decider are unchanged. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugin-approvals/src/approval-service.ts | 35 ++++- .../services/service-automation/src/engine.ts | 141 +++++++++++++++--- .../src/runtime-identity.ts | 26 ++++ packages/spec/src/automation/flow.zod.ts | 21 ++- .../spec/src/contracts/automation-service.ts | 27 +++- 5 files changed, 222 insertions(+), 28 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index b137eedb406..7699c8595fb 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -160,6 +160,15 @@ export interface ApprovalResumeSurface { resume?(runId: string, signal?: { output?: Record; branchLabel?: string; + /** + * The person whose approve / reject decision this resume carries + * (`ResumeSignal.resumedBy`, ruling A on `FlowSchema.runAs`): the same + * person the verdict mirror writes as ({@link actingUserId}). The engine + * runs a `runAs: 'user'` flow's nodes after the decision as them, + * resolving their own grants; absent when no human decided (the SLA + * sweep), and then the run keeps the identity it had. + */ + resumedBy?: string; /** * #3801: the engine refuses a resume of an `approval` suspension unless * the signal carries this marker — the proof that the resume is the tail @@ -772,6 +781,13 @@ export interface StrandedContinuationSignal { decision: string; /** How the original door described the outcome in prose, e.g. `the reject decision`. */ what: string; + /** + * The decider the original resume carried ({@link ApprovalResumeSurface}'s + * `resumedBy`), so a replay continues the nodes after the decision as the + * person who decided, never as the run's trigger user. Absent when the + * original carried none. + */ + resumedBy?: string; } /** @@ -4023,7 +4039,7 @@ export class ApprovalService implements IApprovalService { */ private async serviceResume( runId: string, - signal: { output?: Record; branchLabel?: string }, + signal: { output?: Record; branchLabel?: string; resumedBy?: string }, ): Promise<{ runId: string; repairable: boolean; error: string } | undefined> { const result = await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true }); const reported = result as @@ -4219,7 +4235,7 @@ export class ApprovalService implements IApprovalService { runId: string, requestId: string, what: string, - signal: { output?: Record; branchLabel?: string }, + signal: { output?: Record; branchLabel?: string; resumedBy?: string }, decision: string, ): Promise<{ resumed: boolean; resumeError?: string; resumeFailure?: ResumeFailureReport }> { const missing = this.missingRunCapability(runId, requestId, what, 'resume'); @@ -4279,6 +4295,7 @@ export class ApprovalService implements IApprovalService { if (repairable) { await this.journalStrandedContinuation(requestId, { branchLabel: signal.branchLabel, output: signal.output, decision, what, + ...(signal.resumedBy ? { resumedBy: signal.resumedBy } : {}), }); } throw strandedDecisionFailure( @@ -4321,6 +4338,12 @@ export class ApprovalService implements IApprovalService { const branchLabel = result.decision === 'approve' ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject; + // Ruling A on `FlowSchema.runAs`: the decider rides the hand-off, so the + // nodes after the decision run as the person who decided — the person + // the verdict mirror above already wrote as. The SLA sweep's context + // vouches for nobody, so its auto-decision names no decider and the run + // keeps the identity it had. + const decider = actingUserId(context); const outcome = await this.resumeRecordedOutcome( result.runId, requestId, `the ${result.decision} decision`, { @@ -4331,6 +4354,7 @@ export class ApprovalService implements IApprovalService { // Reserved keys are spread LAST so no output can shadow them (the // whitelist already rejects them; this is defense in depth). output: { ...(result.outputs ?? {}), decision: result.decision, requestId }, + ...(decider ? { resumedBy: decider } : {}), }, result.decision, ); @@ -6410,7 +6434,12 @@ export class ApprovalService implements IApprovalService { // so a continuation that strands AGAIN is repairable exactly like the first. const outcome = await this.resumeRecordedOutcome( runId, requestId, signal.what, - { branchLabel: signal.branchLabel, output: signal.output }, + { + branchLabel: signal.branchLabel, output: signal.output, + // The journalled decider (ruling A): the replayed nodes after the + // decision run as the person who decided, not as the run's trigger. + ...(typeof signal.resumedBy === 'string' && signal.resumedBy ? { resumedBy: signal.resumedBy } : {}), + }, signal.decision, ); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 7d06c7464a8..0f99fd1e010 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -212,7 +212,7 @@ const FLOW_NODE_UNKNOWN_KEY_GUIDANCE: Record> = { '`required` field meant to stay hidden then blocks the screen from ever being submitted.', }, }; -import { runIsUnscopedUserMode, flowTouchesData, runOrgUserIds, type SeededRunContext } from './runtime-identity.js'; +import { runIsUnscopedUserMode, flowTouchesData, runOrgUserIds, runTriggerUserId, type SeededRunContext } from './runtime-identity.js'; import { isGuardRefusal, refuseNode } from './guard-refusal.js'; import { readPartialSteps } from './partial-steps.js'; import { isRegionSuspensionRefusal, refuseRegionSuspension } from './region-suspension-refusal.js'; @@ -1197,7 +1197,9 @@ function buildRunTrigger( : undefined; return { type: context?.event ?? 'manual', - userId: context?.userId, + // The run's TRIGGER user, which a decision switch (ruling A on + // `FlowSchema.runAs`) moves out of `userId` and keeps here. + userId: runTriggerUserId(context), object: context?.object, recordId, }; @@ -6437,6 +6439,51 @@ export class AutomationEngine implements IAutomationService { // human's peers in the run's organization, which is what the request // path's agent envelope carries as well. A context that already carries // one — a `subflow` / `map` child, a resumed leg — keeps it. + await this.seedRunGrants(flow.name, runContext, 'triggering'); + + if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) { + this.logger.warn( + `[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` + + `— its data operations will be REFUSED. Without a user they would carry no principal: ` + + `refused by the security plugin where one is composed, unscoped where none is (the fail-open ADR-0049 forbids). ` + + `Declare runAs:'system' to make the elevation explicit and intended, or arrange ` + + `for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` + + `record-change flow fired by a system write carries no user either (ADR-0049).`, + ); + } + + // #3475 — opt-in single-hop lookup expansion, AFTER identity resolution so + // the re-read runs as this run's own principal (see helper). + await this.expandDeclaredLookups(flow, runContext); + return runContext; + } + + /** + * Seed a `runAs:'user'` run context's authorization facts — `positions`, + * `permissions` and the peer list `org_user_ids` — for its `userId`, through + * the wired grants resolver: the one resolver the request path seeds a + * request's envelope through (`resolveUserAuthzGrants`, bridged by the + * automation plugin). Mutates `runContext`; a fact it already carries is + * kept verbatim, so only the missing ones are resolved (see the setup + * comment in {@link resolveRunContext} for why carried positions and + * permissions are never re-broadened). + * + * Two callers, one resolution: run setup for the TRIGGER user + * ({@link resolveRunContext}), and the decision switch for the DECIDER + * ({@link resolveDecisionSegmentContext}), which hands this a context whose + * trigger-user facts it has already removed — so the decider's segment + * carries the decider's own positions, permission sets and peers, resolved + * exactly as the decider's own request resolves them, and never a copy of + * anybody else's. + * + * A `runAs:'system'` run, a user-less run, a context carrying every fact, + * and an engine with no resolver wired resolve nothing. + */ + private async seedRunGrants( + flowName: string, + runContext: AutomationContext, + whose: 'triggering' | 'deciding', + ): Promise { const needsGrants = !Array.isArray(runContext.permissions); const needsPeers = runOrgUserIds(runContext) === undefined; if ( @@ -6459,7 +6506,7 @@ export class AutomationEngine implements IAutomationService { } } catch (err) { // Fail-safe, never fail-open: on a resolution error the run keeps - // the trigger's (unresolved) identity — the data middleware applies + // the user's (unresolved) identity — the data middleware applies // its baseline member fallback, NOT elevation — and we warn loudly // so the degraded authorization is audible rather than silent. // @@ -6481,28 +6528,73 @@ export class AutomationEngine implements IAutomationService { : `its data ops keep the trigger's positions and permission sets, but every row ` + `policy reading current_user.org_user_ids drops out (narrower, not elevated)`; this.logger.warn( - `[runAs] flow '${flow.name}' could not resolve grants for triggering user ` + + `[runAs] flow '${flowName}' could not resolve grants for ${whose} user ` + `'${runContext.userId}' — ${consequence}. The resolver's failure is in this record's meta.`, describeThrownForLog(err), ); } } + } - if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) { - this.logger.warn( - `[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` + - `— its data operations will be REFUSED. Without a user they would carry no principal: ` + - `refused by the security plugin where one is composed, unscoped where none is (the fail-open ADR-0049 forbids). ` + - `Declare runAs:'system' to make the elevation explicit and intended, or arrange ` + - `for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` + - `record-change flow fired by a system write carries no user either (ADR-0049).`, - ); - } - - // #3475 — opt-in single-hop lookup expansion, AFTER identity resolution so - // the re-read runs as this run's own principal (see helper). - await this.expandDeclaredLookups(flow, runContext); - return runContext; + /** + * The context the nodes after a resumed pause run with (ruling A on + * `FlowSchema.runAs`: 「the human whose act is running this segment: the + * trigger user until a human decision, the decider after it」). + * + * When the signal names the decider of an approval + * ({@link ResumeSignal.resumedBy}) AND carries the owning service's + * {@link RESUME_AUTHORITY_SERVICE} marker, a `runAs:'user'` run continues + * as that decider — identity and permissions together. The answer is a + * COPY of the persisted context with the trigger user's authorization facts + * REMOVED, `userId` set to the decider, and the decider's own positions, + * permission sets and peers resolved by {@link seedRunGrants}, the trigger + * setup's own resolver. Nothing of the trigger user's survives into the + * segment: copying the decision request's envelope by hand, or keeping the + * submitter's grants under the decider's name, would be two identities in + * one context — the reading ruling A rejected. + * + * The trigger is not forgotten: {@link SeededRunContext.triggerUserId} + * records who started the run (`null` when its trigger resolved no user), + * set on the first switch and kept by every later one, so the run's + * history still names its trigger ({@link buildRunTrigger}). The switched + * context is what a later pause in the same run persists, so a second + * decision, a cold resume or a repair replay continues from the decider's + * segment and never falls back to the submitter. + * + * Unchanged, by the letter of the ruling: + * - a `runAs:'system'` run (its posture and its carried user both); + * - a signal with no decider — an SLA auto-decision, a screen, a wait + * timer, every engine-built continuation (a subflow parent's own nodes + * included: the parent declares no decision node of its own); + * - a decider the signal names WITHOUT the service marker: only the + * service that authenticated and recorded the decision may name its + * decider, and an HTTP body cannot carry the symbol; + * - a decider who is already the run's acting user. + * + * On a resolution failure the decider's segment carries no positions or + * permission sets, so its data operations fall back to the baseline member + * set — narrower, never the trigger user's grants (see the warning in + * {@link seedRunGrants}). + */ + private async resolveDecisionSegmentContext( + run: SuspendedRun, + signal: ResumeSignal, + ): Promise { + const context = run.context; + const decider = signal[RESUME_AUTHORITY_SERVICE] === true ? signal.resumedBy : undefined; + if (typeof decider !== 'string' || decider === '') return context; + if (context?.runAs === 'system') return context; + if (context?.userId === decider) return context; + const switched: SeededRunContext = { + ...(context ?? {}), + userId: decider, + triggerUserId: runTriggerUserId(context) ?? null, + }; + delete switched.positions; + delete switched.permissions; + delete switched.org_user_ids; + await this.seedRunGrants(run.flowName, switched, 'deciding'); + return switched; } /** @@ -8127,6 +8219,15 @@ export class AutomationEngine implements IAutomationService { // where a throw is just a value we decline to copy. const variablesAtPause = this.cloneVariablesAtPause(run, runId); + // Ruling A on `FlowSchema.runAs`: who runs the nodes after this + // pause — the decider, when the owning service's signal names one on + // a `runAs:'user'` run; otherwise the identity the run paused with. + // Resolved BEFORE the claim, so nothing here can run after the + // suspension is consumed and leave it consumed with no continuation; + // the persisted `run.context` is left as it was, and the repair + // journal therefore still snapshots the pause exactly as it parked. + const segmentContext = await this.resolveDecisionSegmentContext(run, signal); + // Consume the suspension *before* running downstream work — a run // resumes exactly once per pause, and a duplicate resume after a // partial restart must not double-run side effects. (Folding the @@ -8203,7 +8304,7 @@ export class AutomationEngine implements IAutomationService { await this.forgetSuspendedRun(run, 'resumed', claim.kind === 'claimed'); const steps = run.steps; - const context = run.context; + const context = segmentContext; try { // [#18714] The child this run is parked on REFUSED — on either diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 9032572de01..0bdcca498de 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -136,6 +136,32 @@ export interface RunDataContext extends RunIdentityContext {} */ export interface SeededRunContext extends AutomationContext { org_user_ids?: string[]; + /** + * Who TRIGGERED the run, recorded when a human decision moved the run's + * acting identity (`userId`) to the decider (ruling A on `FlowSchema.runAs`; + * `AutomationEngine.resolveDecisionSegmentContext` is the only writer). + * `null` when the trigger resolved no user. Set on the first switch and kept + * by every later one, and persisted with the rest of the context through a + * later pause, so the run's history names its trigger however many + * decisions it has passed. Absent until a switch: `userId` is then still the + * trigger user ({@link runTriggerUserId}). + */ + triggerUserId?: string | null; +} + +/** + * The run's trigger user: {@link SeededRunContext.triggerUserId} once a + * decision switch recorded it (`undefined` for a trigger that resolved no + * user), and `userId` before any switch — when the two are the same person by + * construction. Attribution of the run, not of its writes: the writes of the + * current segment are `userId`'s. + */ +export function runTriggerUserId(context: AutomationContext | undefined): string | undefined { + const seeded = context as SeededRunContext | undefined; + if (seeded && Object.prototype.hasOwnProperty.call(seeded, 'triggerUserId')) { + return typeof seeded.triggerUserId === 'string' && seeded.triggerUserId !== '' ? seeded.triggerUserId : undefined; + } + return context?.userId; } /** diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 1f0e23538bf..065d2711f6b 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1123,8 +1123,20 @@ export const FlowSchema = lazySchema(() => strictObject( // ADR-0049 / #1888 — ENFORCED. The service-automation engine establishes the // declared identity for the run's data operations and restores the caller's // context afterward: `system` runs elevated (a full-access, RLS-bypassing - // system principal); `user` (default) runs as the triggering user, so CRUD - // nodes' ObjectQL reads/writes respect that user's row-level security. + // system principal); `user` (default) runs as the human whose act is running + // the current segment, so CRUD nodes' ObjectQL reads/writes respect that + // user's row-level security. + // + // That human is the trigger user until a human decision, and the decider + // after it: when an approval decision (approve or reject) resumes the run, + // the nodes after it run with the decider's identity and permissions + // together — their positions, permission sets and organisation peers, + // resolved the way the decider's own request resolves them — while the nodes + // before it keep the trigger user's. So a post-decision write is attributed + // to the decider (`updated_by`, the audit log), and a post-decision node the + // decider may not perform is refused, loudly, and the run reports it; a flow + // whose approval must write what the approver may not declares `system`. A + // flow with no human decision node runs as its trigger user throughout. // // A run under `user` that resolves NO trigger user has nothing to scope to, so // its data operations are REFUSED (#3760). This is NOT a schedule-only case — @@ -1145,7 +1157,10 @@ export const FlowSchema = lazySchema(() => strictObject( .enum(['system', 'user']) .default('user') .describe( - 'Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). ' + + 'Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running ' + + 'this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes ' + + "after an approval decision run with the decider's identity and permissions, so a node the decider may " + + 'not perform is refused). ' + 'A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — ' + 'declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any ' + 'record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses ' + diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 373b1f24337..1f27d6e336a 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -73,7 +73,11 @@ export interface AutomationContext { object?: string; /** Trigger event type (e.g. 'on_create', 'on_update') */ event?: string; - /** User who triggered the automation */ + /** + * The acting user: who triggered the automation — or, on a `runAs: 'user'` + * run that an approval decision resumed, the decider, for the nodes after + * that decision (`FlowSchema.runAs`; {@link ResumeSignal.resumedBy}). + */ userId?: string; /** * Position names of the triggering identity (ADR-0090 D3; formerly @@ -153,7 +157,10 @@ export interface AutomationContext { * carries the triggering user through UNCHANGED whenever the trigger * resolved one (#5494), so its writes still stamp `created_by` / * `updated_by` and `sys_audit_log.user_id` with that human, exactly as the - * same trigger would under `runAs:'user'`. `runAs` declares the run's + * same trigger would under `runAs:'user'` — until a human decision: an + * approval decision switches a `runAs:'user'` run to the decider for the + * nodes after it (`FlowSchema.runAs`), while a `runAs:'system'` run keeps + * carrying its trigger user. `runAs` declares the run's * AUTHORIZATION posture and leaves ATTRIBUTION alone (ADR-0073 D2). * * The `svc:flow:` label is therefore what a genuinely USER-LESS run falls @@ -597,6 +604,22 @@ export interface ResumeSignal { * `{var}` interpolation and conditions read them directly. */ variables?: Record; + /** + * The user whose decision this resume is the tail of — the decider of an + * approval (approve or reject). On a `runAs: 'user'` run the nodes after + * the decision run as this user, with the identity and permissions the + * engine resolves for them through the same grants resolver a trigger + * user's run is seeded through (positions, permission sets, organisation + * peers), never with the trigger user's; see `FlowSchema.runAs`. Absent + * when no human decided (an SLA auto-decision), and then the run keeps the + * identity it had. + * + * Honoured ONLY on a signal that also carries + * {@link RESUME_AUTHORITY_SERVICE}: the owning service names the decider it + * already authenticated and recorded. A signal built from an HTTP body + * cannot carry that marker, so a body naming a user here changes nothing. + */ + resumedBy?: string; /** * Set by the service that OWNS the suspension to clear the resume gate on * a `resumeAuthority: 'service'` node — or on one that declares no From 62e6d8b65f85d2ba5f17802fefb4d720ef890688 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:18:52 +0000 Subject: [PATCH 02/10] test(automation): pin the decider's segment identity on resume A later pause persists the switched segment context, so a second resume that names no decider continues as the decider rather than the trigger user. Pins the data-op envelope (the decider's resolved grants, none of the submitter's), the run history's trigger, the real-stack updated_by attribution, the loud refusal of a node the decider may not perform, and the unchanged controls (runAs:'system', no service marker, no decider, no decision node). Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../services/service-automation/package.json | 1 + .../services/service-automation/src/engine.ts | 7 + ...esume-decider-identity.integration.test.ts | 439 ++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 450 insertions(+) create mode 100644 packages/services/service-automation/src/resume-decider-identity.integration.test.ts diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index a88c39adb3f..f461c406ee3 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -39,6 +39,7 @@ "@objectstack/objectql": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-security": "workspace:*", + "@objectstack/plugin-sharing": "workspace:*", "@objectstack/refd-timer-testkit": "workspace:*", "@objectstack/service-job": "workspace:*", "@objectstack/service-messaging": "workspace:*", diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 0f99fd1e010..7172068a696 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -8527,6 +8527,13 @@ export class AutomationEngine implements IAutomationService { const variablesSnapshot = Object.fromEntries(variables); await this.persistSuspendedRun({ ...run, + // The segment's identity, not the one the run parked + // with last time: after a decision switch (ruling A on + // `FlowSchema.runAs`) the next pause must continue as + // the decider, whatever resumes it — never fall back to + // the trigger user. Identical to `run.context` when no + // switch happened. + context, nodeId: err.nodeId, nodeType: err.nodeType, variables: variablesSnapshot, diff --git a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts new file mode 100644 index 00000000000..f116353a0a1 --- /dev/null +++ b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts @@ -0,0 +1,439 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * After an approval decision, a `runAs:'user'` run continues as the DECIDER — + * identity and permissions together (ruling A on `FlowSchema.runAs`: + * 「the human whose act is running this segment: the trigger user until a + * human decision, the decider after it」). + * + * The defect: a resumed run kept its trigger user for its whole life, so the + * nodes after a manager's approval wrote as the rep who submitted — the + * record's `updated_by` and the audit trail named someone who did not act, and + * the writes ran under the rep's grants rather than the manager's. + * + * Two layers, each pinning what the other cannot see: + * + * 1. THE ENVELOPE (a bare engine, a recording data double, a grants-resolver + * stub): the data context every post-decision node hands the data engine + * is the decider's — their id, positions, permission sets and peers, as + * the resolver answers for THEM — with nothing of the submitter's left in + * it; the nodes before the decision keep the submitter's; the run's + * history keeps naming its trigger; a later pause in the same run does not + * fall back to the submitter. And the controls: a `runAs:'system'` run, a + * signal naming a decider without the owning service's marker, and a + * signal with no decider all continue exactly as before. + * 2. THE REAL STACK (ObjectKernel, ObjectQLPlugin's audit stamps, driver-sql + * on better-sqlite3 `:memory:`, the identity objects, the real + * SecurityPlugin and record sharing, the grants resolver the automation + * plugin bridges): the + * stored `updated_by` names the decider after the decision and the + * submitter before it; the decider's own grants are what admit the write; + * and a post-decision node the decider may not perform — a field their + * permission set marks non-editable, which the submitter's set allows — + * is refused loudly, the run reporting the node, while the same node + * resumed with no decider still lands as the submitter. + * + * The pause here is a stand-in for the `approval` node: a node type that + * declares `resumeAuthority: 'service'`, resumed with the + * `RESUME_AUTHORITY_SERVICE` marker and `resumedBy` exactly as + * `ApprovalService.decide` hands them over (that hand-off is pinned in + * `plugin-approvals`' `decision-resume-decider.test.ts`). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, resolveUserAuthzGrants, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { RESUME_AUTHORITY_SERVICE, type ResumeSignal } from '@objectstack/spec/contracts'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { AutomationEngine } from './engine.js'; +import { AutomationServicePlugin } from './plugin.js'; +import { InMemorySuspendedRunStore } from './suspended-run-store.js'; +import { registerCrudNodes } from './builtin/crud-nodes.js'; + +const silentLogger = (): any => { + const l: any = { info() {}, warn() {}, error() {}, debug() {} }; + l.child = () => l; + return l; +}; + +/** The approval node's stand-in: a pause only its owning service may continue. */ +const GATED = defineActionDescriptor({ + type: 'decision_gate', version: '1.0.0', name: 'Decision Gate', + supportsPause: true, resumeAuthority: 'service', +}); +/** A pause anyone may continue (a screen's shape): the control for the marker. */ +const OPEN = defineActionDescriptor({ + type: 'open_gate', version: '1.0.0', name: 'Open Gate', + supportsPause: true, resumeAuthority: 'any', +}); + +function registerGates(engine: AutomationEngine): void { + engine.registerNodeExecutor({ type: 'decision_gate', descriptor: GATED, async execute() { return { success: true, suspend: true }; } }); + engine.registerNodeExecutor({ type: 'open_gate', descriptor: OPEN, async execute() { return { success: true, suspend: true }; } }); +} + +/** What `ApprovalService.decide` hands the engine for a human decision. */ +const decided = (branchLabel: 'approve' | 'reject', resumedBy?: string): ResumeSignal => ({ + branchLabel, + output: { decision: branchLabel }, + ...(resumedBy ? { resumedBy } : {}), + [RESUME_AUTHORITY_SERVICE]: true, +}); + +const write = (id: string, objectName: string, fields: Record) => ({ + id, type: 'update_record', label: id, + config: { objectName, filter: { id: { dialect: 'cel', source: 'dealId' } }, fields }, +}); + +/** + * start → submit → gate1 ─approve→ after1 → gate2 ─approve→ after2 → end + * └reject→ rejected → end + * Every write is a node of its own, so each one's data context is read apart. + */ +function reviewFlow(name: string, opts: { runAs?: 'system' | 'user'; gate?: string; objectName?: string; approveFields?: Record } = {}) { + const objectName = opts.objectName ?? 'deal'; + return { + name, label: name, type: 'autolaunched', + ...(opts.runAs ? { runAs: opts.runAs } : {}), + variables: [{ name: 'dealId', type: 'text', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + write('submit', objectName, { stage: 'submitted' }), + { id: 'gate1', type: opts.gate ?? 'decision_gate', label: 'Manager review' }, + write('after1', objectName, opts.approveFields ?? { approved_note: 'approved' }), + { id: 'gate2', type: 'decision_gate', label: 'Second review' }, + write('after2', objectName, { stage: 'won' }), + write('rejected', objectName, { stage: 'rejected' }), + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'submit' }, + { id: 'e2', source: 'submit', target: 'gate1' }, + { id: 'e3', source: 'gate1', target: 'after1', label: 'approve' }, + { id: 'e4', source: 'gate1', target: 'rejected', label: 'reject' }, + { id: 'e5', source: 'after1', target: 'gate2' }, + { id: 'e6', source: 'gate2', target: 'after2', label: 'approve' }, + { id: 'e7', source: 'gate2', target: 'rejected', label: 'reject' }, + { id: 'e8', source: 'after2', target: 'end' }, + { id: 'e9', source: 'rejected', target: 'end' }, + ], + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. The envelope +// ───────────────────────────────────────────────────────────────────────────── + +/** Each user's grants as the resolver answers them, deliberately disjoint. */ +const GRANTS: Record = { + usr_rep: { positions: ['sales_rep', 'everyone'], permissions: ['deal_rep'], org_user_ids: ['usr_rep', 'usr_mgr', 'usr_dir'] }, + usr_mgr: { positions: ['sales_manager', 'everyone'], permissions: ['deal_manager'], org_user_ids: ['usr_mgr', 'usr_rep'] }, + usr_dir: { positions: ['sales_director', 'everyone'], permissions: ['deal_director'], org_user_ids: ['usr_dir'] }, +}; + +function envelopeHarness(opts: { failFor?: string } = {}) { + const writes: Array<{ node: unknown; ctx: any }> = []; + const resolved: Array<[string, string | undefined]> = []; + const data: any = { + async find() { return []; }, + async findOne(obj: string, q: any) { assertEngineFindOnePredicate(obj, q); return null; }, + async insert() { return { id: 'x' }; }, + async update(_obj: string, fields: any, opts2: any) { writes.push({ node: fields, ctx: opts2?.context }); return { id: 'd1' }; }, + async delete() { return { ok: true }; }, + }; + const engine = new AutomationEngine(silentLogger(), new InMemorySuspendedRunStore()); + registerCrudNodes(engine, { logger: silentLogger(), getService: (n: string) => (n === 'data' ? data : undefined) } as any); + registerGates(engine); + engine.setUserGrantsResolver(async (userId, tenantId) => { + resolved.push([userId, tenantId]); + if (userId === opts.failFor) throw new Error(`grants store unreachable for ${userId}`); + const g = GRANTS[userId]; + return g ? { ...g, tenantId } : undefined; + }); + /** The data context of the write that set `stage` / `approved_note` to `value`. */ + const ctxOf = (key: string, value: unknown) => writes.find((w) => (w.node as any)?.[key] === value)?.ctx; + return { engine, writes, resolved, ctxOf }; +} + +const TRIGGER = { userId: 'usr_rep', tenantId: 'org_a', params: { dealId: 'd1' } }; + +describe("after an approval decision a runAs:'user' run's data operations carry the decider's identity and permissions", () => { + it('approve: the nodes after the decision run as the decider, with the decider\'s own resolved grants', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review') as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + expect(paused.status).toBe('paused'); + + // Before the decision: the submitter, as today. + const before = h.ctxOf('stage', 'submitted'); + expect(before).toMatchObject({ isSystem: false, userId: 'usr_rep', permissions: ['deal_rep'] }); + expect(before.positions).toContain('sales_rep'); + + const res = await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + expect(res.status, JSON.stringify(res)).toBe('paused'); // parked again at gate2 + + const after = h.ctxOf('approved_note', 'approved'); + expect(after, 'the post-decision write never reached the data engine').toBeTruthy(); + expect(after.isSystem).toBe(false); + expect(after.userId).toBe('usr_mgr'); + expect(after.tenantId).toBe('org_a'); + // Identity AND permissions together: every authorization fact is the + // decider's, resolved for them — none of the submitter's survives. + expect(after.positions).toEqual(GRANTS.usr_mgr.positions); + expect(after.permissions).toEqual(GRANTS.usr_mgr.permissions); + expect(after.org_user_ids).toEqual(GRANTS.usr_mgr.org_user_ids); + expect(after.positions).not.toContain('sales_rep'); + expect(after.permissions).not.toContain('deal_rep'); + // Through the same resolver the trigger was seeded through, for the decider. + expect(h.resolved).toContainEqual(['usr_mgr', 'org_a']); + }); + + it('reject: the reject branch runs as the decider too', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review') as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + const res = await h.engine.resume(paused.runId!, decided('reject', 'usr_mgr')); + expect(res.success, JSON.stringify(res)).toBe(true); + expect(h.ctxOf('stage', 'rejected')).toMatchObject({ userId: 'usr_mgr', permissions: ['deal_manager'] }); + }); + + it('a later pause keeps the decider: a resume that names no decider does not fall back to the submitter', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review') as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + // The second gate continued by its owning service with no human decider + // (an SLA auto-decision's shape). + const res = await h.engine.resume(paused.runId!, decided('approve')); + expect(res.success, JSON.stringify(res)).toBe(true); + expect(h.ctxOf('stage', 'won')).toMatchObject({ userId: 'usr_mgr', permissions: ['deal_manager'] }); + }); + + it('a second decision moves the segment to its own decider, and the run history still names the trigger', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review') as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + const res = await h.engine.resume(paused.runId!, decided('approve', 'usr_dir')); + expect(res.success, JSON.stringify(res)).toBe(true); + expect(h.ctxOf('stage', 'won')).toMatchObject({ userId: 'usr_dir', permissions: ['deal_director'] }); + expect(h.ctxOf('stage', 'won').org_user_ids).toEqual(GRANTS.usr_dir.org_user_ids); + + const run = await h.engine.getRun(paused.runId!); + expect(run?.status).toBe('completed'); + expect(run?.trigger?.userId, 'the run history must keep naming who triggered the run').toBe('usr_rep'); + }); + + it("a decider whose grants cannot be resolved runs on the baseline, never on the submitter's grants", async () => { + const h = envelopeHarness({ failFor: 'usr_mgr' }); + h.engine.registerFlow('review', reviewFlow('review') as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + const after = h.ctxOf('approved_note', 'approved'); + expect(after.userId).toBe('usr_mgr'); + expect(after.positions).toEqual([]); + expect(after.permissions).toEqual([]); + expect(after.org_user_ids).toBeUndefined(); + }); + + describe('controls — unchanged', () => { + it("CONTROL: a runAs:'system' run keeps its elevation and its carried trigger user", async () => { + const h = envelopeHarness(); + h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system' }) as never); + const paused = await h.engine.execute('sys_review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + expect(h.ctxOf('approved_note', 'approved')).toMatchObject({ isSystem: true, userId: 'usr_rep', positions: [], permissions: [] }); + }); + + it('CONTROL: a decider named WITHOUT the owning service\'s marker changes nothing', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('open_review', reviewFlow('open_review', { gate: 'open_gate' }) as never); + const paused = await h.engine.execute('open_review', { ...TRIGGER } as never); + // What an HTTP body can build: no symbol-keyed marker. + const res = await h.engine.resume(paused.runId!, { branchLabel: 'approve', resumedBy: 'usr_mgr' }); + expect(res.status, JSON.stringify(res)).toBe('paused'); + expect(h.ctxOf('approved_note', 'approved')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + }); + + it('CONTROL: a service resume that names no decider (an SLA auto-decision) keeps the run as it was', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review') as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve')); + expect(h.ctxOf('approved_note', 'approved')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + }); + + it('CONTROL: a flow with no human decision node runs as its trigger user throughout', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('straight', { + name: 'straight', label: 'straight', type: 'autolaunched', + variables: [{ name: 'dealId', type: 'text', isInput: true }], + nodes: [{ id: 'start', type: 'start', label: 'Start' }, write('w', 'deal', { stage: 'straight' }), { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'w' }, { id: 'e2', source: 'w', target: 'end' }], + } as never); + const res = await h.engine.execute('straight', { ...TRIGGER } as never); + expect(res.success, JSON.stringify(res)).toBe(true); + expect(h.ctxOf('stage', 'straight')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. The real stack +// ───────────────────────────────────────────────────────────────────────────── + +const SYS = { isSystem: true } as const; + +/** + * `updated_by` / `created_by` / `owner_id` are injected by the registry, as on + * an app object. `private`, as HotCRM's `crm_opportunity` is: record sharing + * then owns the write verdict, so the manager's `modifyAllRecords` is what + * reaches a deal the rep owns (the platform's ownership floor defers to it). + */ +const dealObject = { + name: 'deal', + label: 'Deal', + sharingModel: 'private', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + stage: { name: 'stage', label: 'Stage', type: 'text' }, + approved_note: { name: 'approved_note', label: 'Approved Note', type: 'text' }, + restricted_note: { name: 'restricted_note', label: 'Restricted Note', type: 'text' }, + }, +}; + +/** The rep edits their own deals, every field. */ +const dealRepSet = PermissionSetSchema.parse({ + name: 'deal_rep', label: 'Deal rep', + objects: { deal: { allowRead: true, allowCreate: true, allowEdit: true } }, +}); +/** The manager edits every deal — but may not edit `restricted_note`. */ +const dealManagerSet = PermissionSetSchema.parse({ + name: 'deal_manager', label: 'Deal manager', + objects: { deal: { allowRead: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true } }, + fields: { 'deal.restricted_note': { readable: true, editable: false } }, +}); + +describe('on the real stack: attribution after the decision, and the refusal when the decider lacks a right', () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let automation: AutomationEngine; + let orgA = ''; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', type: 'standard', version: '1.0.0', + async init(ctx: PluginContext) { ctx.registerService('driver.default', driver); }, + }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, dealRepSet, dealManagerSet] })); + await kernel.use(new SharingServicePlugin()); + await kernel.bootstrap(); + + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + registerGates(automation); + ql.registry.registerObject(dealObject as any, 'decider-test', 'decider-test'); + await ql.syncSchemas(); + + const orgs = await ql.find('sys_organization', { context: SYS }); + orgA = String(orgs[0].id); + for (const [id, name] of [['usr_rep', 'Rep'], ['usr_mgr', 'Manager']]) { + await ql.insert('sys_user', { id, name, email: `${id}@example.test` }, { context: SYS }); + await ql.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: orgA, role: 'member' }, { context: SYS }); + } + await ql.insert('sys_user_permission_set', { id: 'ups_rep', user_id: 'usr_rep', permission_set: 'deal_rep', organization_id: orgA }, { context: SYS }); + await ql.insert('sys_user_permission_set', { id: 'ups_mgr', user_id: 'usr_mgr', permission_set: 'deal_manager', organization_id: orgA }, { context: SYS }); + + automation.registerFlow('deal_review', reviewFlow('deal_review') as never); + automation.registerFlow('deal_review_restricted', reviewFlow('deal_review_restricted', { approveFields: { restricted_note: 'set after the decision' } }) as never); + }, 30_000); + + afterAll(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + /** What the request path hands ObjectQL for `userId` acting in the org. */ + const requestContext = async (userId: string) => { + const g = await resolveUserAuthzGrants(ql, userId, { tenantId: orgA, bypassGrantsCache: true }); + return { userId, tenantId: orgA, isSystem: false, positions: g.positions, permissions: g.permissions, org_user_ids: g.org_user_ids }; + }; + const stored = async (id: string) => (await ql.findOne('deal', { where: { id }, context: SYS })) as any; + + /** The rep creates a deal through their own request, then submits it. */ + async function submitted(flow: string, title: string) { + const created: any = await ql.insert('deal', { title }, { context: await requestContext('usr_rep') }); + const dealId = String(created.id); + const paused = await automation.execute(flow, { userId: 'usr_rep', tenantId: orgA, params: { dealId } } as never); + expect(paused.status, JSON.stringify(paused)).toBe('paused'); + const row = await stored(dealId); + expect(row.stage).toBe('submitted'); + expect(row.updated_by, 'guard: the submitter wrote the record last before the decision').toBe('usr_rep'); + return { dealId, runId: paused.runId! }; + } + + it("GUARD: the manager's write rests on the manager's own grants — under the rep's sets it is refused", async () => { + // The fixture would be measuring nothing if any member could write a + // rep's deal: the default member scope keys writes on `created_by`, so it + // is `deal_manager`'s `modifyAllRecords` that admits the manager below. + const created: any = await ql.insert('deal', { title: 'guard' }, { context: await requestContext('usr_rep') }); + const repSets = (await requestContext('usr_rep')).permissions; + await expect( + ql.update('deal', { id: created.id, approved_note: 'x' }, { context: { userId: 'usr_mgr', tenantId: orgA, isSystem: false, positions: ['org_member', 'everyone'], permissions: repSets } }), + ).rejects.toThrow(); + }); + + it('approve: `updated_by` names the manager after the decision, and the submitter before it', async () => { + const { dealId, runId } = await submitted('deal_review', 'approve me'); + const res = await automation.resume(runId, decided('approve', 'usr_mgr')); + expect(res.status, JSON.stringify(res)).toBe('paused'); + const row = await stored(dealId); + expect(row.approved_note).toBe('approved'); + expect(row.updated_by, 'the post-decision write is attributed to the decider').toBe('usr_mgr'); + expect(row.created_by).toBe('usr_rep'); + }); + + it('reject: the reject branch is attributed to the manager', async () => { + const { dealId, runId } = await submitted('deal_review', 'reject me'); + const res = await automation.resume(runId, decided('reject', 'usr_mgr')); + expect(res.success, JSON.stringify(res)).toBe(true); + const row = await stored(dealId); + expect(row.stage).toBe('rejected'); + expect(row.updated_by).toBe('usr_mgr'); + }); + + it('a post-decision node the decider may not perform is refused loudly, and the run reports the node', async () => { + const { dealId, runId } = await submitted('deal_review_restricted', 'restricted'); + const res: any = await automation.resume(runId, decided('approve', 'usr_mgr')); + expect(res.success).toBe(false); + expect(res.status, 'the run stops at the refused node, repairable').toBe('stranded'); + expect(res.error, 'the report names the refused node').toContain('after1'); + expect(res.error, 'and the field the decider may not write').toContain('restricted_note'); + const row = await stored(dealId); + expect(row.restricted_note ?? null, 'nothing landed').toBeNull(); + expect(row.updated_by).toBe('usr_rep'); + + const run = await automation.getRun(runId); + expect(run?.status).toBe('failed'); + expect(run?.steps?.find((s: any) => s.nodeId === 'after1')?.status).toBe('failure'); + }); + + it('CONTROL: the same node resumed with no decider lands as the submitter, whose set allows it', async () => { + const { dealId, runId } = await submitted('deal_review_restricted', 'restricted, no decider'); + const res = await automation.resume(runId, decided('approve')); + expect(res.status, JSON.stringify(res)).toBe('paused'); + const row = await stored(dealId); + expect(row.restricted_note).toBe('set after the decision'); + expect(row.updated_by).toBe('usr_rep'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9049ea5cebe..990d69f341a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2529,6 +2529,9 @@ importers: '@objectstack/plugin-security': specifier: workspace:* version: link:../../plugins/plugin-security + '@objectstack/plugin-sharing': + specifier: workspace:* + version: link:../../plugins/plugin-sharing '@objectstack/refd-timer-testkit': specifier: workspace:* version: link:../../qa/refd-timer-testkit From df0b4dd7bf1b17f158152a8978b6888697a9c61f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:21:05 +0000 Subject: [PATCH 03/10] test(approvals): the decision hand-off carries the decider decide resumes the run as the person who decided, with their own grants; the SLA sweep's auto-decision names no decider; a stranded decision journals its decider and the repair replay continues as them. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../restored-approval-continuation.test.ts | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts index 5fcbfa759de..a0501311b09 100644 --- a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts +++ b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts @@ -39,7 +39,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation'; +import { AutomationEngine, InMemorySuspendedRunStore, resolveRunDataContext } from '@objectstack/service-automation'; // [#4550] The engine double below routes its write verbs through ObjectQL's OWN // dispatch predicates rather than a hand-mirrored copy. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; @@ -893,3 +893,129 @@ describe('#15389 — a restored approval suspension has an issuer again', () => expect(await second.hasSuspendedRun(amb.flow_run_id)).toBe(true); }); }); + +/** + * Ruling A on `FlowSchema.runAs` — the decider rides the decision hand-off. + * + * 「the human whose act is running this segment: the trigger user until a + * human decision, the decider after it」. `decide` hands the engine the person + * who decided (the same person the verdict mirror writes as) on the + * service-stamped resume signal, and the engine runs a `runAs: 'user'` flow's + * nodes after the decision as them, with their own resolved grants. The SLA + * sweep's auto-decision vouches for nobody, so it names no decider. And the + * decider is journalled with a stranded continuation, so the repair replay + * (#15389, this file's subject) continues as the person who decided — never + * falling back to the run's trigger user. + * + * Read on the run's own data context, `resolveRunDataContext(context)`: the + * envelope every CRUD node hands ObjectQL. The engine-side switch, and the + * attribution on a real stack, are pinned in `service-automation`'s + * `resume-decider-identity.integration.test.ts`. + */ +describe('ruling A — a decision resumes the run as the person who decided', () => { + const DECIDER_CTX = { userId: 'u1', isSystem: false, positions: [], permissions: [] } as any; + const GRANTS: Record = { + submitter: { positions: ['sales_rep'], permissions: ['deal_rep'] }, + u1: { positions: ['sales_manager'], permissions: ['deal_manager'] }, + }; + /** start → before → approval ─approve→ after_approve / ─reject→ after_reject → end */ + const WHO_FLOW = { + name: 'who_flow', label: 'Who Flow', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'before', type: 'whoami', label: 'Before' }, + { id: 'review', type: 'approval', label: 'Review', config: { approvers: [{ type: 'user', value: 'u1' }] } }, + { id: 'after_approve', type: 'whoami', label: 'After approve' }, + { id: 'after_reject', type: 'whoami', label: 'After reject' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'before' }, + { id: 'e2', source: 'before', target: 'review' }, + { id: 'e3', source: 'review', target: 'after_approve', label: 'approve' }, + { id: 'e4', source: 'review', target: 'after_reject', label: 'reject' }, + { id: 'e5', source: 'after_approve', target: 'end' }, + { id: 'e6', source: 'after_reject', target: 'end' }, + ], + }; + + let data: ReturnType; + let service: ApprovalService; + let seen: Record; + let rejectThrowsOnce: boolean; + + function boot() { + const automation = new AutomationEngine(noopLogger as any, new InMemorySuspendedRunStore()); + automation.setUserGrantsResolver(async (userId) => GRANTS[userId]); + registerApprovalNode(automation, service, noopLogger as any); + automation.registerNodeExecutor({ + type: 'whoami', + async execute(node: any, _vars: any, context: any) { + if (node.id === 'after_reject' && rejectThrowsOnce) { + rejectThrowsOnce = false; + throw new Error(DOWNSTREAM_FAILURE); + } + const ctx = resolveRunDataContext(context); + seen[node.id] = { userId: ctx?.userId, permissions: ctx?.permissions }; + return { success: true }; + }, + } as never); + automation.registerFlow('who_flow', WHO_FLOW as never); + service.attachAutomation(automation); + return automation; + } + + beforeEach(() => { + seen = {}; rejectThrowsOnce = false; + data = makeFakeEngine(); + service = new ApprovalService({ engine: data as any, logger: noopLogger }); + }); + + const rowOf = async (id: string) => + (await data.find('sys_approval_request', { where: { id } }))[0]; + + async function parked(automation: AutomationEngine) { + await automation.execute('who_flow', { + object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter', + } as never); + expect(seen.before, 'the nodes before the decision run as the submitter').toEqual({ + userId: 'submitter', permissions: ['deal_rep'], + }); + return (await data.find('sys_approval_request', { where: { status: 'pending' } }))[0]; + } + + it.each(['approve', 'reject'] as const)('%s: the nodes after the decision run as the decider, with the decider\'s grants', async (decision) => { + const automation = boot(); + const req = await parked(automation); + const out = await service.decide(req.id, { decision, comment: decision }, DECIDER_CTX); + expect(out.resumed, JSON.stringify(out)).toBe(true); + expect(seen[`after_${decision}`]).toEqual({ userId: 'u1', permissions: ['deal_manager'] }); + }); + + it('CONTROL: an SLA auto-decision names no decider, so the run keeps the identity it had', async () => { + const automation = boot(); + const req = await parked(automation); + // The sweep's shape: a system context that vouches for nobody. + const out = await service.decide(req.id, { decision: 'approve', actorId: 'u1', comment: 'SLA escalation' }, SYSTEM_CTX); + expect(out.resumed, JSON.stringify(out)).toBe(true); + expect(seen.after_approve).toEqual({ userId: 'submitter', permissions: ['deal_rep'] }); + }); + + it('a stranded decision is replayed as the person who decided, not as the trigger user', async () => { + const automation = boot(); + const req = await parked(automation); + rejectThrowsOnce = true; + const err = await service.decide(req.id, { decision: 'reject', comment: 'no' }, DECIDER_CTX) + .then(() => null, (e: Error) => e); + expect(err?.message).toMatch(/^RESUME_FAILED/); + // The journal carries the decider beside the signal it re-issues. + const journal = JSON.parse((await rowOf(req.id)).node_config_json).__strandedContinuation; + expect(journal).toMatchObject({ decision: 'reject', resumedBy: 'u1' }); + + expect((await automation.restoreConsumedSuspension(req.flow_run_id)).restored).toBe(true); + const out = await service.continueRestoredRun(req.id, { requestedBy: 'ops', reason: 'fixed the record' }); + expect(out.resumed, JSON.stringify(out)).toBe(true); + expect(out.source).toBe('journal'); + expect(seen.after_reject, 'the replay fell back to the trigger user').toEqual({ userId: 'u1', permissions: ['deal_manager'] }); + }); +}); From 048d653ba4b5b1846126cee544cb62056cd28fbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:35:33 +0000 Subject: [PATCH 04/10] docs(spec): regenerate the runAs reference and add the changeset Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .changeset/24321-resume-as-decider.md | 25 +++++++++++++++++++ .../docs/references/api/automation-api.mdx | 10 ++++---- content/docs/references/automation/flow.mdx | 4 +-- 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 .changeset/24321-resume-as-decider.md diff --git a/.changeset/24321-resume-as-decider.md b/.changeset/24321-resume-as-decider.md new file mode 100644 index 00000000000..8ea6bb03d73 --- /dev/null +++ b/.changeset/24321-resume-as-decider.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/plugin-approvals': minor +--- + +feat(automation)!: an approval decision resumes a `runAs: 'user'` flow as the person who decided + +Clause-②: no (narrowing). A post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes. + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and the writes ran under the rep's grants. + +`flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. + +- **Now:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it. +- **Refused now:** a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too. +- **Unchanged:** a `runAs: 'system'` flow, including the trigger user it carries for attribution; a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had; a subflow parent's own nodes; and the send-back, resubmit and recall doors. + +`@objectstack/plugin-approvals` hands the decider to the engine on the service-stamped resume signal: the person the verdict mirror already writes as. The engine honours a decider only on a signal that carries the owning service's `RESUME_AUTHORITY_SERVICE` marker, so a resume built from an HTTP body cannot name one. The published types gain one optional member each: `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`), and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). + +What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`, which is unchanged by this release. A run that was suspended before this release and is decided after it continues as the decider. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 084d5c5b344..623fde4c0e1 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -120,7 +120,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -244,7 +244,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -375,7 +375,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -683,7 +683,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -748,7 +748,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 75652cf4ea4..03da681692b 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -41,7 +41,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -262,7 +262,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | From 3b181b18d5b919ff08aa691870986e5c090673e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:48:44 +0000 Subject: [PATCH 05/10] test(approvals): name the decider as the actor on the decision input Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../src/restored-approval-continuation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts index a0501311b09..4312b156a68 100644 --- a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts +++ b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts @@ -987,7 +987,7 @@ describe('ruling A — a decision resumes the run as the person who decided', () it.each(['approve', 'reject'] as const)('%s: the nodes after the decision run as the decider, with the decider\'s grants', async (decision) => { const automation = boot(); const req = await parked(automation); - const out = await service.decide(req.id, { decision, comment: decision }, DECIDER_CTX); + const out = await service.decide(req.id, { decision, actorId: 'u1', comment: decision }, DECIDER_CTX); expect(out.resumed, JSON.stringify(out)).toBe(true); expect(seen[`after_${decision}`]).toEqual({ userId: 'u1', permissions: ['deal_manager'] }); }); @@ -1005,7 +1005,7 @@ describe('ruling A — a decision resumes the run as the person who decided', () const automation = boot(); const req = await parked(automation); rejectThrowsOnce = true; - const err = await service.decide(req.id, { decision: 'reject', comment: 'no' }, DECIDER_CTX) + const err = await service.decide(req.id, { decision: 'reject', actorId: 'u1', comment: 'no' }, DECIDER_CTX) .then(() => null, (e: Error) => e); expect(err?.message).toMatch(/^RESUME_FAILED/); // The journal carries the decider beside the signal it re-issues. From bc19d6e1d6ea0b61b94bf780a053aec05883c065 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:24:59 +0000 Subject: [PATCH 06/10] test(automation): read the decider envelope off a recording node, alias plugin-sharing The envelope pins now record each post-decision node's data context with a recording node instead of a data-engine double, and the new plugin-sharing import resolves to source like the package's other aliases. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- ...esume-decider-identity.integration.test.ts | 97 ++++++++++--------- .../service-automation/vitest.config.ts | 9 ++ 2 files changed, 61 insertions(+), 45 deletions(-) diff --git a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts index f116353a0a1..d055a6fbe17 100644 --- a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts +++ b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts @@ -13,8 +13,8 @@ * * Two layers, each pinning what the other cannot see: * - * 1. THE ENVELOPE (a bare engine, a recording data double, a grants-resolver - * stub): the data context every post-decision node hands the data engine + * 1. THE ENVELOPE (a bare engine, a recording node, a grants-resolver stub): + * the data context every post-decision node hands the data engine * is the decider's — their id, positions, permission sets and peers, as * the resolver answers for THEM — with nothing of the submitter's left in * it; the nodes before the decision keep the submitter's; the run's @@ -50,11 +50,10 @@ import { SharingServicePlugin } from '@objectstack/plugin-sharing'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import { RESUME_AUTHORITY_SERVICE, type ResumeSignal } from '@objectstack/spec/contracts'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { AutomationEngine } from './engine.js'; import { AutomationServicePlugin } from './plugin.js'; import { InMemorySuspendedRunStore } from './suspended-run-store.js'; -import { registerCrudNodes } from './builtin/crud-nodes.js'; +import { resolveRunDataContext } from './runtime-identity.js'; const silentLogger = (): any => { const l: any = { info() {}, warn() {}, error() {}, debug() {} }; @@ -86,30 +85,39 @@ const decided = (branchLabel: 'approve' | 'reject', resumedBy?: string): ResumeS [RESUME_AUTHORITY_SERVICE]: true, }); -const write = (id: string, objectName: string, fields: Record) => ({ - id, type: 'update_record', label: id, - config: { objectName, filter: { id: { dialect: 'cel', source: 'dealId' } }, fields }, -}); +/** + * A post-decision write. On the real stack it is an `update_record`; in the + * envelope layer it is `record_identity`, a node that records exactly what a + * CRUD node hands ObjectQL as `options.context` — `resolveRunDataContext` of + * the run context it was given — so no data-engine double stands in. + */ +const write = (id: string, objectName: string, fields: Record, recorder = false) => (recorder + ? { id, type: 'record_identity', label: id } + : { + id, type: 'update_record', label: id, + config: { objectName, filter: { id: { dialect: 'cel', source: 'dealId' } }, fields }, + }); /** * start → submit → gate1 ─approve→ after1 → gate2 ─approve→ after2 → end * └reject→ rejected → end * Every write is a node of its own, so each one's data context is read apart. */ -function reviewFlow(name: string, opts: { runAs?: 'system' | 'user'; gate?: string; objectName?: string; approveFields?: Record } = {}) { +function reviewFlow(name: string, opts: { runAs?: 'system' | 'user'; gate?: string; objectName?: string; approveFields?: Record; recorder?: boolean } = {}) { const objectName = opts.objectName ?? 'deal'; + const r = opts.recorder === true; return { name, label: name, type: 'autolaunched', ...(opts.runAs ? { runAs: opts.runAs } : {}), variables: [{ name: 'dealId', type: 'text', isInput: true }], nodes: [ { id: 'start', type: 'start', label: 'Start' }, - write('submit', objectName, { stage: 'submitted' }), + write('submit', objectName, { stage: 'submitted' }, r), { id: 'gate1', type: opts.gate ?? 'decision_gate', label: 'Manager review' }, - write('after1', objectName, opts.approveFields ?? { approved_note: 'approved' }), + write('after1', objectName, opts.approveFields ?? { approved_note: 'approved' }, r), { id: 'gate2', type: 'decision_gate', label: 'Second review' }, - write('after2', objectName, { stage: 'won' }), - write('rejected', objectName, { stage: 'rejected' }), + write('after2', objectName, { stage: 'won' }, r), + write('rejected', objectName, { stage: 'rejected' }, r), { id: 'end', type: 'end', label: 'End' }, ], edges: [ @@ -138,27 +146,26 @@ const GRANTS: Record = []; + const seen = new Map(); const resolved: Array<[string, string | undefined]> = []; - const data: any = { - async find() { return []; }, - async findOne(obj: string, q: any) { assertEngineFindOnePredicate(obj, q); return null; }, - async insert() { return { id: 'x' }; }, - async update(_obj: string, fields: any, opts2: any) { writes.push({ node: fields, ctx: opts2?.context }); return { id: 'd1' }; }, - async delete() { return { ok: true }; }, - }; const engine = new AutomationEngine(silentLogger(), new InMemorySuspendedRunStore()); - registerCrudNodes(engine, { logger: silentLogger(), getService: (n: string) => (n === 'data' ? data : undefined) } as any); registerGates(engine); + engine.registerNodeExecutor({ + type: 'record_identity', + async execute(node, _variables, context) { + seen.set(node.id, resolveRunDataContext(context)); + return { success: true }; + }, + }); engine.setUserGrantsResolver(async (userId, tenantId) => { resolved.push([userId, tenantId]); if (userId === opts.failFor) throw new Error(`grants store unreachable for ${userId}`); const g = GRANTS[userId]; return g ? { ...g, tenantId } : undefined; }); - /** The data context of the write that set `stage` / `approved_note` to `value`. */ - const ctxOf = (key: string, value: unknown) => writes.find((w) => (w.node as any)?.[key] === value)?.ctx; - return { engine, writes, resolved, ctxOf }; + /** The data context the named post-decision node would have handed ObjectQL. */ + const ctxOf = (nodeId: string) => seen.get(nodeId); + return { engine, resolved, ctxOf }; } const TRIGGER = { userId: 'usr_rep', tenantId: 'org_a', params: { dealId: 'd1' } }; @@ -166,19 +173,19 @@ const TRIGGER = { userId: 'usr_rep', tenantId: 'org_a', params: { dealId: 'd1' } describe("after an approval decision a runAs:'user' run's data operations carry the decider's identity and permissions", () => { it('approve: the nodes after the decision run as the decider, with the decider\'s own resolved grants', async () => { const h = envelopeHarness(); - h.engine.registerFlow('review', reviewFlow('review') as never); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); const paused = await h.engine.execute('review', { ...TRIGGER } as never); expect(paused.status).toBe('paused'); // Before the decision: the submitter, as today. - const before = h.ctxOf('stage', 'submitted'); + const before = h.ctxOf('submit'); expect(before).toMatchObject({ isSystem: false, userId: 'usr_rep', permissions: ['deal_rep'] }); expect(before.positions).toContain('sales_rep'); const res = await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); expect(res.status, JSON.stringify(res)).toBe('paused'); // parked again at gate2 - const after = h.ctxOf('approved_note', 'approved'); + const after = h.ctxOf('after1'); expect(after, 'the post-decision write never reached the data engine').toBeTruthy(); expect(after.isSystem).toBe(false); expect(after.userId).toBe('usr_mgr'); @@ -196,34 +203,34 @@ describe("after an approval decision a runAs:'user' run's data operations carry it('reject: the reject branch runs as the decider too', async () => { const h = envelopeHarness(); - h.engine.registerFlow('review', reviewFlow('review') as never); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); const paused = await h.engine.execute('review', { ...TRIGGER } as never); const res = await h.engine.resume(paused.runId!, decided('reject', 'usr_mgr')); expect(res.success, JSON.stringify(res)).toBe(true); - expect(h.ctxOf('stage', 'rejected')).toMatchObject({ userId: 'usr_mgr', permissions: ['deal_manager'] }); + expect(h.ctxOf('rejected')).toMatchObject({ userId: 'usr_mgr', permissions: ['deal_manager'] }); }); it('a later pause keeps the decider: a resume that names no decider does not fall back to the submitter', async () => { const h = envelopeHarness(); - h.engine.registerFlow('review', reviewFlow('review') as never); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); const paused = await h.engine.execute('review', { ...TRIGGER } as never); await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); // The second gate continued by its owning service with no human decider // (an SLA auto-decision's shape). const res = await h.engine.resume(paused.runId!, decided('approve')); expect(res.success, JSON.stringify(res)).toBe(true); - expect(h.ctxOf('stage', 'won')).toMatchObject({ userId: 'usr_mgr', permissions: ['deal_manager'] }); + expect(h.ctxOf('after2')).toMatchObject({ userId: 'usr_mgr', permissions: ['deal_manager'] }); }); it('a second decision moves the segment to its own decider, and the run history still names the trigger', async () => { const h = envelopeHarness(); - h.engine.registerFlow('review', reviewFlow('review') as never); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); const paused = await h.engine.execute('review', { ...TRIGGER } as never); await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); const res = await h.engine.resume(paused.runId!, decided('approve', 'usr_dir')); expect(res.success, JSON.stringify(res)).toBe(true); - expect(h.ctxOf('stage', 'won')).toMatchObject({ userId: 'usr_dir', permissions: ['deal_director'] }); - expect(h.ctxOf('stage', 'won').org_user_ids).toEqual(GRANTS.usr_dir.org_user_ids); + expect(h.ctxOf('after2')).toMatchObject({ userId: 'usr_dir', permissions: ['deal_director'] }); + expect(h.ctxOf('after2').org_user_ids).toEqual(GRANTS.usr_dir.org_user_ids); const run = await h.engine.getRun(paused.runId!); expect(run?.status).toBe('completed'); @@ -232,10 +239,10 @@ describe("after an approval decision a runAs:'user' run's data operations carry it("a decider whose grants cannot be resolved runs on the baseline, never on the submitter's grants", async () => { const h = envelopeHarness({ failFor: 'usr_mgr' }); - h.engine.registerFlow('review', reviewFlow('review') as never); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); const paused = await h.engine.execute('review', { ...TRIGGER } as never); await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); - const after = h.ctxOf('approved_note', 'approved'); + const after = h.ctxOf('after1'); expect(after.userId).toBe('usr_mgr'); expect(after.positions).toEqual([]); expect(after.permissions).toEqual([]); @@ -245,28 +252,28 @@ describe("after an approval decision a runAs:'user' run's data operations carry describe('controls — unchanged', () => { it("CONTROL: a runAs:'system' run keeps its elevation and its carried trigger user", async () => { const h = envelopeHarness(); - h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system' }) as never); + h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system', recorder: true }) as never); const paused = await h.engine.execute('sys_review', { ...TRIGGER } as never); await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); - expect(h.ctxOf('approved_note', 'approved')).toMatchObject({ isSystem: true, userId: 'usr_rep', positions: [], permissions: [] }); + expect(h.ctxOf('after1')).toMatchObject({ isSystem: true, userId: 'usr_rep', positions: [], permissions: [] }); }); it('CONTROL: a decider named WITHOUT the owning service\'s marker changes nothing', async () => { const h = envelopeHarness(); - h.engine.registerFlow('open_review', reviewFlow('open_review', { gate: 'open_gate' }) as never); + h.engine.registerFlow('open_review', reviewFlow('open_review', { gate: 'open_gate', recorder: true }) as never); const paused = await h.engine.execute('open_review', { ...TRIGGER } as never); // What an HTTP body can build: no symbol-keyed marker. const res = await h.engine.resume(paused.runId!, { branchLabel: 'approve', resumedBy: 'usr_mgr' }); expect(res.status, JSON.stringify(res)).toBe('paused'); - expect(h.ctxOf('approved_note', 'approved')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + expect(h.ctxOf('after1')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); }); it('CONTROL: a service resume that names no decider (an SLA auto-decision) keeps the run as it was', async () => { const h = envelopeHarness(); - h.engine.registerFlow('review', reviewFlow('review') as never); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); const paused = await h.engine.execute('review', { ...TRIGGER } as never); await h.engine.resume(paused.runId!, decided('approve')); - expect(h.ctxOf('approved_note', 'approved')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + expect(h.ctxOf('after1')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); }); it('CONTROL: a flow with no human decision node runs as its trigger user throughout', async () => { @@ -274,12 +281,12 @@ describe("after an approval decision a runAs:'user' run's data operations carry h.engine.registerFlow('straight', { name: 'straight', label: 'straight', type: 'autolaunched', variables: [{ name: 'dealId', type: 'text', isInput: true }], - nodes: [{ id: 'start', type: 'start', label: 'Start' }, write('w', 'deal', { stage: 'straight' }), { id: 'end', type: 'end', label: 'End' }], + nodes: [{ id: 'start', type: 'start', label: 'Start' }, write('w', 'deal', { stage: 'straight' }, true), { id: 'end', type: 'end', label: 'End' }], edges: [{ id: 'e1', source: 'start', target: 'w' }, { id: 'e2', source: 'w', target: 'end' }], } as never); const res = await h.engine.execute('straight', { ...TRIGGER } as never); expect(res.success, JSON.stringify(res)).toBe(true); - expect(h.ctxOf('stage', 'straight')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + expect(h.ctxOf('w')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); }); }); }); diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts index 8d57372290b..ef682b86216 100644 --- a/packages/services/service-automation/vitest.config.ts +++ b/packages/services/service-automation/vitest.config.ts @@ -81,6 +81,15 @@ export default defineConfig({ find: /^@objectstack\/plugin-auth$/, replacement: path.resolve(__dirname, '../../plugins/plugin-auth/src/index.ts'), }, + { + // `resume-decider-identity.integration.test.ts` composes record sharing + // (`SharingServicePlugin`): a `private` object's write verdict is the + // sharing service's, which is what lets a manager's `modifyAllRecords` + // reach a deal the rep owns. Read from source for the reason the entries + // above give. Same anchored-regex rule. + find: /^@objectstack\/plugin-sharing$/, + replacement: path.resolve(__dirname, '../../plugins/plugin-sharing/src/index.ts'), + }, ], }, }); From e89fbde96defb1faff5b87263a92d230c2363d0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:02:29 +0000 Subject: [PATCH 07/10] feat(automation): a runAs:'system' run's attribution moves to the decider Ruling 6267719791 Q1 A: after an approval decision a runAs:'system' run keeps its elevation and its nodes carry the decider as their attribution user ({ isSystem: true, userId: decider }), with no grant resolved and the trigger user's authorization facts removed. The runAs describe, the ResumeSignal / AutomationContext TSDoc and the approvals hand-off docs say so. plugin-audit joins service-automation's test devDependencies. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugin-approvals/src/approval-service.ts | 5 ++-- .../services/service-automation/package.json | 1 + .../services/service-automation/src/engine.ts | 24 ++++++++++++---- .../src/runtime-identity.ts | 8 ++++-- .../service-automation/vitest.config.ts | 8 ++++++ packages/spec/src/automation/flow.zod.ts | 11 ++++++-- .../spec/src/contracts/automation-service.ts | 28 +++++++++++-------- pnpm-lock.yaml | 3 ++ 8 files changed, 64 insertions(+), 24 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 7699c8595fb..740b9c8cc0f 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -165,8 +165,9 @@ export interface ApprovalResumeSurface { * (`ResumeSignal.resumedBy`, ruling A on `FlowSchema.runAs`): the same * person the verdict mirror writes as ({@link actingUserId}). The engine * runs a `runAs: 'user'` flow's nodes after the decision as them, - * resolving their own grants; absent when no human decided (the SLA - * sweep), and then the run keeps the identity it had. + * resolving their own grants, and a `runAs: 'system'` flow's with them as + * the attribution user of its unchanged elevation; absent when no human + * decided (the SLA sweep), and then the run keeps the identity it had. */ resumedBy?: string; /** diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index f461c406ee3..c1a9a206f64 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -37,6 +37,7 @@ "@objectstack/driver-sql": "workspace:*", "@objectstack/metadata-core": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 7172068a696..019ddb6c6fd 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -6561,8 +6561,19 @@ export class AutomationEngine implements IAutomationService { * decision, a cold resume or a repair replay continues from the decider's * segment and never falls back to the submitter. * - * Unchanged, by the letter of the ruling: - * - a `runAs:'system'` run (its posture and its carried user both); + * A `runAs:'system'` run moves its ATTRIBUTION user and nothing else (ruling + * 6267719791 Q1 A: 「`'system'` flows keep system privileges; after the + * decision their attribution user is the decider」). Its nodes after the + * decision run as `{ isSystem: true, userId: DECIDER }` — the shape the + * approvals verdict mirror already writes with — so `updated_by`, the audit + * row and `ctx.user` in the hooks those writes fire name the decider, while + * elevation is untouched and no grant is resolved (a system run's data + * context carries none: {@link resolveRunDataContext}'s system arm). The + * trigger user's positions, permission sets and peers are removed here too, + * so `current_user` in the segment is the decider and never the decider's id + * over the submitter's grants. + * + * Unchanged: * - a signal with no decider — an SLA auto-decision, a screen, a wait * timer, every engine-built continuation (a subflow parent's own nodes * included: the parent declares no decision node of its own); @@ -6583,7 +6594,6 @@ export class AutomationEngine implements IAutomationService { const context = run.context; const decider = signal[RESUME_AUTHORITY_SERVICE] === true ? signal.resumedBy : undefined; if (typeof decider !== 'string' || decider === '') return context; - if (context?.runAs === 'system') return context; if (context?.userId === decider) return context; const switched: SeededRunContext = { ...(context ?? {}), @@ -6593,6 +6603,8 @@ export class AutomationEngine implements IAutomationService { delete switched.positions; delete switched.permissions; delete switched.org_user_ids; + // A system run keeps its elevation: attribution moves, no grant is resolved. + if (switched.runAs === 'system') return switched; await this.seedRunGrants(run.flowName, switched, 'deciding'); return switched; } @@ -8220,8 +8232,10 @@ export class AutomationEngine implements IAutomationService { const variablesAtPause = this.cloneVariablesAtPause(run, runId); // Ruling A on `FlowSchema.runAs`: who runs the nodes after this - // pause — the decider, when the owning service's signal names one on - // a `runAs:'user'` run; otherwise the identity the run paused with. + // pause — the decider, when the owning service's signal names one + // (with their own grants on a `runAs:'user'` run, as the attribution + // user of a still-elevated `runAs:'system'` run); otherwise the + // identity the run paused with. // Resolved BEFORE the claim, so nothing here can run after the // suspension is consumed and leave it consumed with no continuation; // the persisted `run.context` is left as it was, and the repair diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 0bdcca498de..f7d04696289 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -27,8 +27,9 @@ export interface RunIdentityContext { actor?: string; /** * Acting user id. On a `runAs:'user'` run it drives owner/role RLS. On a - * `runAs:'system'` run it is carried through UNCHANGED from the trigger when - * one resolved a user (#5494): elevation and anonymity are separate choices + * `runAs:'system'` run it is carried through from the trigger when one + * resolved a user (#5494), and after an approval decision it is the decider + * (ruling 6267719791 Q1 A): elevation and anonymity are separate choices * (ADR-0073's authorization-vs-attribution axes; the #3783 approvals-mirror * `{ isSystem: true, userId }` shape), and the security middleware's * `isSystem` short-circuit precedes every gate that reads `userId`, so under @@ -180,7 +181,8 @@ export function runOrgUserIds(context: AutomationContext | undefined): string[] * * - `runAs:'system'` → `{ isSystem: true, actor: 'svc:flow:' }`, * PLUS the trigger's `userId` / `tenantId` when the trigger resolved them - * (#5494) — the security middleware short-circuits on `isSystem`, so the + * (#5494; after an approval decision `userId` is the decider, the run's + * elevation unchanged) — the security middleware short-circuits on `isSystem`, so the * run reads/writes with full access, bypassing RLS; the `actor` label and * the carried-through acting user keep those writes attributable in the * audit log and in the rows' own `created_by`/`updated_by` stamps diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts index ef682b86216..e3be7f2489b 100644 --- a/packages/services/service-automation/vitest.config.ts +++ b/packages/services/service-automation/vitest.config.ts @@ -90,6 +90,14 @@ export default defineConfig({ find: /^@objectstack\/plugin-sharing$/, replacement: path.resolve(__dirname, '../../plugins/plugin-sharing/src/index.ts'), }, + { + // The same suite reads the `sys_audit_log` rows the post-decision writes + // leave (`AuditPlugin`'s writers), so the attribution it pins is the + // audit trail's own, not only the row's `updated_by`. Read from source + // for the reason the entries above give. Same anchored-regex rule. + find: /^@objectstack\/plugin-audit$/, + replacement: path.resolve(__dirname, '../../plugins/plugin-audit/src/index.ts'), + }, ], }, }); diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 065d2711f6b..bdb67419d8f 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1135,8 +1135,12 @@ export const FlowSchema = lazySchema(() => strictObject( // before it keep the trigger user's. So a post-decision write is attributed // to the decider (`updated_by`, the audit log), and a post-decision node the // decider may not perform is refused, loudly, and the run reports it; a flow - // whose approval must write what the approver may not declares `system`. A - // flow with no human decision node runs as its trigger user throughout. + // whose approval must write what the approver may not declares `system`. + // `system` flows keep system privileges; after the decision their attribution + // user is the decider (`{ isSystem: true, userId: }`, the shape the + // approvals verdict mirror writes with), so their post-decision writes, and + // `ctx.user` in the hooks those writes fire, name the decider too. A flow + // with no human decision node runs as its trigger user throughout. // // A run under `user` that resolves NO trigger user has nothing to scope to, so // its data operations are REFUSED (#3760). This is NOT a schedule-only case — @@ -1157,7 +1161,8 @@ export const FlowSchema = lazySchema(() => strictObject( .enum(['system', 'user']) .default('user') .describe( - 'Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running ' + + 'Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after ' + + 'an approval decision their attribution user is the decider; user = the human whose act is running ' + 'this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes ' + "after an approval decision run with the decider's identity and permissions, so a node the decider may " + 'not perform is refused). ' + diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 1f27d6e336a..30a2dc07821 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -74,9 +74,10 @@ export interface AutomationContext { /** Trigger event type (e.g. 'on_create', 'on_update') */ event?: string; /** - * The acting user: who triggered the automation — or, on a `runAs: 'user'` - * run that an approval decision resumed, the decider, for the nodes after - * that decision (`FlowSchema.runAs`; {@link ResumeSignal.resumedBy}). + * The acting user: who triggered the automation — or, on a run that an + * approval decision resumed, the decider, for the nodes after that + * decision, on both postures (`FlowSchema.runAs`; + * {@link ResumeSignal.resumedBy}). */ userId?: string; /** @@ -154,13 +155,15 @@ export interface AutomationContext { * the write. The label is a FALLBACK, not a replacement — the audit writer * records `session.userId ?? session.actor` on `sys_audit_log.actor` — and * elevation never costs the run its operator: a `runAs:'system'` run - * carries the triggering user through UNCHANGED whenever the trigger - * resolved one (#5494), so its writes still stamp `created_by` / - * `updated_by` and `sys_audit_log.user_id` with that human, exactly as the - * same trigger would under `runAs:'user'` — until a human decision: an - * approval decision switches a `runAs:'user'` run to the decider for the - * nodes after it (`FlowSchema.runAs`), while a `runAs:'system'` run keeps - * carrying its trigger user. `runAs` declares the run's + * carries the triggering user through whenever the trigger resolved one + * (#5494), so its writes still stamp `created_by` / `updated_by` and + * `sys_audit_log.user_id` with that human, exactly as the same trigger + * would under `runAs:'user'` — and after a human decision that parity + * holds with the decider: an approval decision moves the acting user to + * the decider for the nodes after it on BOTH postures (`FlowSchema.runAs`). + * A `runAs:'user'` run takes the decider's permissions with them; a + * `runAs:'system'` run keeps its elevation and only its attribution user + * moves. `runAs` declares the run's * AUTHORIZATION posture and leaves ATTRIBUTION alone (ADR-0073 D2). * * The `svc:flow:` label is therefore what a genuinely USER-LESS run falls @@ -610,7 +613,10 @@ export interface ResumeSignal { * the decision run as this user, with the identity and permissions the * engine resolves for them through the same grants resolver a trigger * user's run is seeded through (positions, permission sets, organisation - * peers), never with the trigger user's; see `FlowSchema.runAs`. Absent + * peers), never with the trigger user's. On a `runAs: 'system'` run they + * keep the run's elevation and carry this user as their attribution user + * (`{ isSystem: true, userId }`), with no grant resolved; see + * `FlowSchema.runAs`. Absent * when no human decided (an SLA auto-decision), and then the run keeps the * identity it had. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 990d69f341a..5edd43cbf57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2523,6 +2523,9 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../../objectql + '@objectstack/plugin-audit': + specifier: workspace:* + version: link:../../plugins/plugin-audit '@objectstack/plugin-auth': specifier: workspace:* version: link:../../plugins/plugin-auth From 8d55469508a3e0f1854a79eeb118df422212f560 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:11:42 +0000 Subject: [PATCH 08/10] test(automation): pin a system run's decider attribution after a decision The runAs:'system' control becomes attribution pins: after approve and reject the post-decision writes keep their elevation (a readonly verdict column lands) while updated_by, the sys_audit_log row and the data context name the decider, with no grant resolved. A system run resumed with no decider keeps the submitter. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../restored-approval-continuation.test.ts | 23 ++-- ...esume-decider-identity.integration.test.ts | 111 ++++++++++++++++-- 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts index 4312b156a68..d90533affac 100644 --- a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts +++ b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts @@ -941,7 +941,7 @@ describe('ruling A — a decision resumes the run as the person who decided', () let data: ReturnType; let service: ApprovalService; - let seen: Record; + let seen: Record; let rejectThrowsOnce: boolean; function boot() { @@ -956,11 +956,12 @@ describe('ruling A — a decision resumes the run as the person who decided', () throw new Error(DOWNSTREAM_FAILURE); } const ctx = resolveRunDataContext(context); - seen[node.id] = { userId: ctx?.userId, permissions: ctx?.permissions }; + seen[node.id] = { userId: ctx?.userId, permissions: ctx?.permissions, ...(ctx?.isSystem ? { isSystem: true } : {}) }; return { success: true }; }, } as never); automation.registerFlow('who_flow', WHO_FLOW as never); + automation.registerFlow('who_flow_sys', { ...WHO_FLOW, name: 'who_flow_sys', runAs: 'system' } as never); service.attachAutomation(automation); return automation; } @@ -974,13 +975,13 @@ describe('ruling A — a decision resumes the run as the person who decided', () const rowOf = async (id: string) => (await data.find('sys_approval_request', { where: { id } }))[0]; - async function parked(automation: AutomationEngine) { - await automation.execute('who_flow', { + async function parked(automation: AutomationEngine, flow = 'who_flow') { + await automation.execute(flow, { object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter', } as never); - expect(seen.before, 'the nodes before the decision run as the submitter').toEqual({ - userId: 'submitter', permissions: ['deal_rep'], - }); + expect(seen.before, 'the nodes before the decision run as the submitter').toEqual(flow === 'who_flow' + ? { userId: 'submitter', permissions: ['deal_rep'] } + : { userId: 'submitter', permissions: [], isSystem: true }); return (await data.find('sys_approval_request', { where: { status: 'pending' } }))[0]; } @@ -992,6 +993,14 @@ describe('ruling A — a decision resumes the run as the person who decided', () expect(seen[`after_${decision}`]).toEqual({ userId: 'u1', permissions: ['deal_manager'] }); }); + it.each(['approve', 'reject'] as const)("runAs:'system' %s: the nodes after the decision keep their elevation and carry the decider as their attribution user (ruling 6267719791 Q1 A)", async (decision) => { + const automation = boot(); + const req = await parked(automation, 'who_flow_sys'); + const out = await service.decide(req.id, { decision, actorId: 'u1', comment: decision }, DECIDER_CTX); + expect(out.resumed, JSON.stringify(out)).toBe(true); + expect(seen[`after_${decision}`]).toEqual({ userId: 'u1', permissions: [], isSystem: true }); + }); + it('CONTROL: an SLA auto-decision names no decider, so the run keeps the identity it had', async () => { const automation = boot(); const req = await parked(automation); diff --git a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts index d055a6fbe17..fe9cbbab558 100644 --- a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts +++ b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts @@ -19,15 +19,19 @@ * the resolver answers for THEM — with nothing of the submitter's left in * it; the nodes before the decision keep the submitter's; the run's * history keeps naming its trigger; a later pause in the same run does not - * fall back to the submitter. And the controls: a `runAs:'system'` run, a - * signal naming a decider without the owning service's marker, and a - * signal with no decider all continue exactly as before. + * fall back to the submitter. A `runAs:'system'` run keeps its elevation + * and carries the decider as its attribution user, with no grant resolved + * (ruling 6267719791 Q1 A). And the controls: a signal naming a decider + * without the owning service's marker, and a signal with no decider (on + * both postures), all continue exactly as before. * 2. THE REAL STACK (ObjectKernel, ObjectQLPlugin's audit stamps, driver-sql * on better-sqlite3 `:memory:`, the identity objects, the real * SecurityPlugin and record sharing, the grants resolver the automation * plugin bridges): the - * stored `updated_by` names the decider after the decision and the - * submitter before it; the decider's own grants are what admit the write; + * stored `updated_by` and the `sys_audit_log` row (AuditPlugin's + * writers) name the decider after the decision and the submitter before + * it — on a `runAs:'system'` run too, whose readonly verdict column still + * lands under its unchanged elevation (ruling 6267719791 Q1 A); the decider's own grants are what admit the write; * and a post-decision node the decider may not perform — a field their * permission set marks non-editable, which the submitter's set allows — * is refused loudly, the run reporting the node, while the same node @@ -47,6 +51,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { AuditPlugin } from '@objectstack/plugin-audit'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import { RESUME_AUTHORITY_SERVICE, type ResumeSignal } from '@objectstack/spec/contracts'; @@ -103,7 +108,7 @@ const write = (id: string, objectName: string, fields: Record, * └reject→ rejected → end * Every write is a node of its own, so each one's data context is read apart. */ -function reviewFlow(name: string, opts: { runAs?: 'system' | 'user'; gate?: string; objectName?: string; approveFields?: Record; recorder?: boolean } = {}) { +function reviewFlow(name: string, opts: { runAs?: 'system' | 'user'; gate?: string; objectName?: string; approveFields?: Record; rejectFields?: Record; recorder?: boolean } = {}) { const objectName = opts.objectName ?? 'deal'; const r = opts.recorder === true; return { @@ -117,7 +122,7 @@ function reviewFlow(name: string, opts: { runAs?: 'system' | 'user'; gate?: stri write('after1', objectName, opts.approveFields ?? { approved_note: 'approved' }, r), { id: 'gate2', type: 'decision_gate', label: 'Second review' }, write('after2', objectName, { stage: 'won' }, r), - write('rejected', objectName, { stage: 'rejected' }, r), + write('rejected', objectName, opts.rejectFields ?? { stage: 'rejected' }, r), { id: 'end', type: 'end', label: 'End' }, ], edges: [ @@ -153,7 +158,12 @@ function envelopeHarness(opts: { failFor?: string } = {}) { engine.registerNodeExecutor({ type: 'record_identity', async execute(node, _variables, context) { - seen.set(node.id, resolveRunDataContext(context)); + seen.set(node.id, { + ...resolveRunDataContext(context), + // The run context's own facts, beside the envelope: what `current_user` + // is built from in the segment's CEL. + raw: { userId: context?.userId, positions: context?.positions, permissions: context?.permissions }, + }); return { success: true }; }, }); @@ -249,12 +259,42 @@ describe("after an approval decision a runAs:'user' run's data operations carry expect(after.org_user_ids).toBeUndefined(); }); + it.each([['approve', 'after1'], ['reject', 'rejected']] as const)( + "runAs:'system' %s: the nodes after the decision keep their elevation and carry the decider as their attribution user, with no grant resolved", + async (decision, nodeId) => { + const h = envelopeHarness(); + h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system', recorder: true }) as never); + // The REST / action doors' shape: the trigger forwards its own grants. + const paused = await h.engine.execute('sys_review', { ...TRIGGER, positions: ['sales_rep'], permissions: ['deal_rep'] } as never); + expect(h.ctxOf('submit')).toMatchObject({ isSystem: true, userId: 'usr_rep' }); + + await h.engine.resume(paused.runId!, decided(decision, 'usr_mgr')); + const after = h.ctxOf(nodeId); + // Ruling 6267719791 Q1 A: `{ isSystem: true, userId: }`. + expect(after).toMatchObject({ isSystem: true, userId: 'usr_mgr', positions: [], permissions: [], tenantId: 'org_a' }); + expect(after.org_user_ids).toBeUndefined(); + // No permission resolution for a system run, and none of the submitter's + // grants left under the decider's name. + expect(h.resolved.map(([u]) => u), 'a system run resolves no grant for the decider').not.toContain('usr_mgr'); + expect(after.raw).toEqual({ userId: 'usr_mgr', positions: undefined, permissions: undefined }); + }, + ); + + it("runAs:'system': the run history still names the trigger after the decider takes over", async () => { + const h = envelopeHarness(); + h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system', recorder: true }) as never); + const paused = await h.engine.execute('sys_review', { ...TRIGGER } as never); + const res = await h.engine.resume(paused.runId!, decided('reject', 'usr_mgr')); + expect(res.success, JSON.stringify(res)).toBe(true); + expect((await h.engine.getRun(paused.runId!))?.trigger?.userId).toBe('usr_rep'); + }); + describe('controls — unchanged', () => { - it("CONTROL: a runAs:'system' run keeps its elevation and its carried trigger user", async () => { + it("CONTROL: a runAs:'system' run resumed with no decider keeps the user it carried", async () => { const h = envelopeHarness(); h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system', recorder: true }) as never); const paused = await h.engine.execute('sys_review', { ...TRIGGER } as never); - await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + await h.engine.resume(paused.runId!, decided('approve')); expect(h.ctxOf('after1')).toMatchObject({ isSystem: true, userId: 'usr_rep', positions: [], permissions: [] }); }); @@ -312,6 +352,10 @@ const dealObject = { stage: { name: 'stage', label: 'Stage', type: 'text' }, approved_note: { name: 'approved_note', label: 'Approved Note', type: 'text' }, restricted_note: { name: 'restricted_note', label: 'Restricted Note', type: 'text' }, + // HotCRM's verdict-column shape (`approval_status`, `approved_date`): a + // static `readonly` column objectql strips from every non-system UPDATE, + // so only an elevated write lands it. + verdict: { name: 'verdict', label: 'Verdict', type: 'text', readonly: true }, }, }; @@ -345,6 +389,7 @@ describe('on the real stack: attribution after the decision, and the refusal whe await kernel.use(createIdentityObjectsPlugin()); await kernel.use(new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, dealRepSet, dealManagerSet] })); await kernel.use(new SharingServicePlugin()); + await kernel.use(new AuditPlugin()); await kernel.bootstrap(); ql = kernel.getService('objectql'); @@ -364,6 +409,11 @@ describe('on the real stack: attribution after the decision, and the refusal whe automation.registerFlow('deal_review', reviewFlow('deal_review') as never); automation.registerFlow('deal_review_restricted', reviewFlow('deal_review_restricted', { approveFields: { restricted_note: 'set after the decision' } }) as never); + automation.registerFlow('deal_review_system', reviewFlow('deal_review_system', { + runAs: 'system', + approveFields: { verdict: 'approved', approved_note: 'approved' }, + rejectFields: { verdict: 'rejected', stage: 'rejected' }, + }) as never); }, 30_000); afterAll(async () => { @@ -376,6 +426,10 @@ describe('on the real stack: attribution after the decision, and the refusal whe return { userId, tenantId: orgA, isSystem: false, positions: g.positions, permissions: g.permissions, org_user_ids: g.org_user_ids }; }; const stored = async (id: string) => (await ql.findOne('deal', { where: { id }, context: SYS })) as any; + /** The `sys_audit_log` UPDATE rows `AuditPlugin`'s writers left for the deal, oldest first. */ + const auditUpdates = async (id: string) => + ((await ql.find('sys_audit_log', { where: { object_name: 'deal', record_id: id, action: 'update' }, context: SYS })) as any[]) + .map((r) => ({ user_id: r.user_id, new_value: JSON.parse(String(r.new_value ?? '{}')) })); /** The rep creates a deal through their own request, then submits it. */ async function submitted(flow: string, title: string) { @@ -408,6 +462,9 @@ describe('on the real stack: attribution after the decision, and the refusal whe expect(row.approved_note).toBe('approved'); expect(row.updated_by, 'the post-decision write is attributed to the decider').toBe('usr_mgr'); expect(row.created_by).toBe('usr_rep'); + const audit = await auditUpdates(dealId); + expect(audit.find((a) => a.new_value.approved_note === 'approved')?.user_id, 'the audit row names the decider').toBe('usr_mgr'); + expect(audit.find((a) => a.new_value.stage === 'submitted')?.user_id, 'the pre-decision write stays the submitter\'s').toBe('usr_rep'); }); it('reject: the reject branch is attributed to the manager', async () => { @@ -417,6 +474,7 @@ describe('on the real stack: attribution after the decision, and the refusal whe const row = await stored(dealId); expect(row.stage).toBe('rejected'); expect(row.updated_by).toBe('usr_mgr'); + expect((await auditUpdates(dealId)).find((a) => a.new_value.stage === 'rejected')?.user_id).toBe('usr_mgr'); }); it('a post-decision node the decider may not perform is refused loudly, and the run reports the node', async () => { @@ -435,6 +493,39 @@ describe('on the real stack: attribution after the decision, and the refusal whe expect(run?.steps?.find((s: any) => s.nodeId === 'after1')?.status).toBe('failure'); }); + it("GUARD: `verdict` is a column only an elevated write lands — the manager's own request is stripped of it", async () => { + const created: any = await ql.insert('deal', { title: 'verdict guard' }, { context: await requestContext('usr_rep') }); + await ql.update('deal', { id: created.id, verdict: 'hand-set', approved_note: 'by hand' }, { context: await requestContext('usr_mgr') }); + const row = await stored(String(created.id)); + expect(row.approved_note, 'guard: the manager may write this deal').toBe('by hand'); + expect(row.verdict ?? null, 'a non-system write cannot land the readonly column').toBeNull(); + }); + + it.each([['approve', 'approved'], ['reject', 'rejected']] as const)( + "runAs:'system' %s: the readonly verdict lands under unchanged elevation, and `updated_by` and the audit row name the manager", + async (decision, verdict) => { + const { dealId, runId } = await submitted('deal_review_system', `system ${decision}`); + const res = await automation.resume(runId, decided(decision, 'usr_mgr')); + expect(res.success, JSON.stringify(res)).not.toBe(false); + const row = await stored(dealId); + expect(row.verdict, 'elevation unchanged: the readonly column landed').toBe(verdict); + expect(row.updated_by, 'the post-decision write is attributed to the decider').toBe('usr_mgr'); + const audit = await auditUpdates(dealId); + expect(audit.find((a) => a.new_value.verdict === verdict)?.user_id, 'the audit row names the decider').toBe('usr_mgr'); + expect(audit.find((a) => a.new_value.stage === 'submitted')?.user_id, "the pre-decision write stays the submitter's").toBe('usr_rep'); + }, + ); + + it("CONTROL: a runAs:'system' run resumed with no decider keeps the submitter", async () => { + const { dealId, runId } = await submitted('deal_review_system', 'system, no decider'); + const res = await automation.resume(runId, decided('approve')); + expect(res.success, JSON.stringify(res)).not.toBe(false); + const row = await stored(dealId); + expect(row.verdict).toBe('approved'); + expect(row.updated_by).toBe('usr_rep'); + expect((await auditUpdates(dealId)).find((a) => a.new_value.verdict === 'approved')?.user_id).toBe('usr_rep'); + }); + it('CONTROL: the same node resumed with no decider lands as the submitter, whose set allows it', async () => { const { dealId, runId } = await submitted('deal_review_restricted', 'restricted, no decider'); const res = await automation.resume(runId, decided('approve')); From 6513db320695878d41e5d40e386290ab5f0bd5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:17:49 +0000 Subject: [PATCH 09/10] docs(spec): the runAs describe and changeset carry the system-posture ruling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-② re-graded yes (narrowing) for the three optional resumedBy members; the reference docs are regenerated by check:generated --fix. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .changeset/24321-resume-as-decider.md | 19 ++++++++++--------- .../docs/references/api/automation-api.mdx | 10 +++++----- content/docs/references/automation/flow.mdx | 4 ++-- packages/spec/src/automation/flow.zod.ts | 2 +- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.changeset/24321-resume-as-decider.md b/.changeset/24321-resume-as-decider.md index 8ea6bb03d73..eb527329711 100644 --- a/.changeset/24321-resume-as-decider.md +++ b/.changeset/24321-resume-as-decider.md @@ -4,22 +4,23 @@ '@objectstack/plugin-approvals': minor --- -feat(automation)!: an approval decision resumes a `runAs: 'user'` flow as the person who decided +feat(automation)!: an approval decision resumes a flow as the person who decided -Clause-②: no (narrowing). A post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes. +Clause-②: yes (narrowing). The narrowing: under `runAs: 'user'`, a post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). The widening: three published interfaces each gain one optional member, `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`) and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes. - + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. -A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and the writes ran under the rep's grants. +A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and under `runAs: 'user'` the writes ran under the rep's grants. -`flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. +`flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. `runAs: 'system'` flows keep system privileges; after the decision their attribution user is the decider. -- **Now:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it. -- **Refused now:** a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too. -- **Unchanged:** a `runAs: 'system'` flow, including the trigger user it carries for attribution; a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had; a subflow parent's own nodes; and the send-back, resubmit and recall doors. +- **Now, `runAs: 'user'`:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it. +- **Now, `runAs: 'system'`:** after an approval decision, the flow keeps system privileges and its nodes run as `{ isSystem: true, userId: DECIDER }`, the shape the approvals verdict mirror already writes with. No permission is resolved and elevation is unchanged, so a read-only column still lands. Their writes, `sys_audit_log`, and `ctx.user` in the hooks those writes fire name the decider; the nodes before the decision keep the submitter. +- **Refused now:** under `runAs: 'user'`, a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too. +- **Unchanged:** a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had (on both postures); a subflow parent's own nodes; and the send-back, resubmit and recall doors. `@objectstack/plugin-approvals` hands the decider to the engine on the service-stamped resume signal: the person the verdict mirror already writes as. The engine honours a decider only on a signal that carries the owning service's `RESUME_AUTHORITY_SERVICE` marker, so a resume built from an HTTP body cannot name one. The published types gain one optional member each: `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`), and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). -What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`, which is unchanged by this release. A run that was suspended before this release and is decided after it continues as the decider. +What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`; its writes then keep system privileges and are attributed to the approver. A hook that reads `ctx.user` on a write a `runAs: 'system'` approval flow makes after the decision now sees the decider instead of the submitter. A run that was suspended before this release and is decided after it continues as the decider. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 623fde4c0e1..859088d9d8a 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -120,7 +120,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -244,7 +244,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -375,7 +375,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -683,7 +683,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -748,7 +748,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 03da681692b..bdc45c63ecb 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -41,7 +41,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -262,7 +262,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index bdb67419d8f..0b4978714ab 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1137,7 +1137,7 @@ export const FlowSchema = lazySchema(() => strictObject( // decider may not perform is refused, loudly, and the run reports it; a flow // whose approval must write what the approver may not declares `system`. // `system` flows keep system privileges; after the decision their attribution - // user is the decider (`{ isSystem: true, userId: }`, the shape the + // user is the decider (`{ isSystem: true, userId: DECIDER }`, the shape the // approvals verdict mirror writes with), so their post-decision writes, and // `ctx.user` in the hooks those writes fire, name the decider too. A flow // with no human decision node runs as its trigger user throughout. From be9efd1c54e4d84d9f9862abbed7c0b327947ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:52:38 +0000 Subject: [PATCH 10/10] docs(automation): the flows guide states the decider's segment identity The runAs row and the resume section of the hand-written flows guide now say what the describe says: a 'user' run is the trigger user until a human decision and the decider after it, a 'system' run's attribution user is the decider after a decision, and a resumed run continues under the identity stored on the run. The decider pin file's header names the approvals test that holds the hand-off pins. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- content/docs/automation/flows.mdx | 6 +++--- .../src/resume-decider-identity.integration.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 2e548b906ad..045fec7b2a8 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -73,7 +73,7 @@ const approvalFlow = { | `version` | `number` | optional | Version number (defaults to `1`) | | `status` | `enum` | optional | `'draft'`, `'active'`, `'obsolete'`, `'invalid'` (defaults to `'draft'`) | | `type` | `FlowType` | ✅ | Flow trigger type (see below) | -| `runAs` | `enum` | optional | `'system'` (elevated, bypasses RLS) or `'user'` (the triggering user, RLS-respecting; the default). A `'user'` run that resolved **no** trigger user has nothing to scope to, so its data operations are refused — declare `'system'` for schedule / time-relative / API triggers and for record-change flows fired by a write that carried no user | +| `runAs` | `enum` | optional | `'system'` (elevated, bypasses RLS; after an approval decision its attribution user is the decider) or `'user'` (the default, RLS-respecting: the trigger user until a human decision, the decider after it, with the decider's permissions). A `'user'` run that resolved **no** trigger user has nothing to scope to, so its data operations are refused — declare `'system'` for schedule / time-relative / API triggers and for record-change flows fired by a write that carried no user | | `variables` | `FlowVariable[]` | optional | Input/output variables | | `nodes` | `FlowNode[]` | ✅ | Flow nodes | | `edges` | `FlowEdge[]` | ✅ | Connections between nodes | @@ -1088,8 +1088,8 @@ referent there. The shape is `ResumeFailureDetailsSchema` in The route asks two questions, in this order. Both must pass. **1. Who is resuming.** A resumed run continues under the identity stored on -the run, so its data nodes run as the user who **started** it, whoever sent the -values. The route therefore admits only: +the run — the user who **started** it or, after an approval decision, the +decider — whoever sent the values. The route therefore admits only: - the user who started the run (the run's `trigger.userId`) — no extra permission needed; diff --git a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts index fe9cbbab558..40e0fc06a3c 100644 --- a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts +++ b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts @@ -41,7 +41,7 @@ * declares `resumeAuthority: 'service'`, resumed with the * `RESUME_AUTHORITY_SERVICE` marker and `resumedBy` exactly as * `ApprovalService.decide` hands them over (that hand-off is pinned in - * `plugin-approvals`' `decision-resume-decider.test.ts`). + * `plugin-approvals`' `restored-approval-continuation.test.ts`). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest';