diff --git a/.changeset/approval-reassign-structured-parties.md b/.changeset/approval-reassign-structured-parties.md new file mode 100644 index 0000000000..95fcc1e481 --- /dev/null +++ b/.changeset/approval-reassign-structured-parties.md @@ -0,0 +1,24 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-approvals": minor +--- + +feat(approvals,spec): structured reassign hand-off parties on `sys_approval_action` (#4365) + +A reassign's audit row used to encode "who handed the slot to whom" only inside +a default free-text comment — `""`, two raw user ids — which +clients could neither parse reliably nor render readably, so the approvals +timeline showed opaque identifier soup for the single most important fact of +the entry. + +- `sys_approval_action` gains `reassign_from` / `reassign_to` + (`lookup('sys_user')`), written by `ApprovalService.reassign()`. +- `comment` is pure user input again: nothing is invented when the actor + supplies none. +- `listActions()` resolves both parties' display names into + `reassign_from_name` / `reassign_to_name`, alongside the existing + `actor_name`, so timelines can render "from A to B" without extra lookups. +- `ApprovalActionRow` (spec contract) declares the four new fields. + +Pre-existing rows keep their legacy comment; clients should prefer the +structured fields when present and fall back to `comment` otherwise. diff --git a/.changeset/flow-system-run-audit-attribution.md b/.changeset/flow-system-run-audit-attribution.md new file mode 100644 index 0000000000..d7fcfb8f46 --- /dev/null +++ b/.changeset/flow-system-run-audit-attribution.md @@ -0,0 +1,29 @@ +--- +"@objectstack/spec": patch +"@objectstack/service-automation": patch +"@objectstack/objectql": patch +--- + +fix(automation,objectql,spec): attribute `runAs:'system'` flow writes to the flow in the audit log (#4366) + +A `runAs:'system'` flow's data writes carried no attribution at all: the run +context resolved to `{ isSystem: true }` with no `userId` and no service +principal, so the audit writer recorded `user_id=null, actor=null` and the +record-history UI rendered every such row as "Unknown user" — business users +read the flow's own status mirror as data corruption. + +The `svc:*` attribution channel (ADR-0014 D2, `ExecutionContext.actor`) already +existed for exactly this class of writer; it was simply never wired end-to-end: + +- **service-automation** — `resolveRunContext` now stamps `flowName` alongside + `runAs`/`flowRunId`, and `resolveRunDataContext` labels a `runAs:'system'` + run's data context `actor: 'svc:flow:'` (fallback + `svc:flow:automation`). Attribution only — no security middleware keys on it. +- **objectql** — `buildSession` propagates `ExecutionContext.actor` onto the + hook session, closing the gap that left the audit writer's + `userId ?? session.actor` fallback unreachable from the engine path. +- **spec** — `AutomationContext.flowName` (engine-stamped, provenance) and the + hook session's optional `actor` field document the contract. + +No behavior change for user-attributed writes: `userId` still wins wherever it +is present. diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 24a2e60b35..f586181537 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -37,7 +37,7 @@ const result = HookContext.parse(data); | **input** | `Record` | ✅ | Mutable input parameters | | **result** | `any` | optional | Operation result (After hooks only) | | **previous** | `Record` | optional | Record state before operation | -| **session** | `{ userId?: string; organizationId?: string; roles?: string[]; accessToken?: string; … }` | optional | Current session context | +| **session** | `{ userId?: string; actor?: string; organizationId?: string; roles?: string[]; … }` | optional | Current session context | | **provenance** | `{ flowRunId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | | **transaction** | `any` | optional | Database transaction handle | | **ql** | `any` | ✅ | ObjectQL Engine Reference | diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index dfa0b799cb..e9664abb1d 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -484,6 +484,39 @@ describe('ObjectQL Engine', () => { }); }); + /** + * ADR-0014 D2 / #4366 — the service-principal label. A non-user write (a + * `runAs:'system'` flow, a service token) carries `ExecutionContext.actor`; + * the audit writer's `userId ?? session.actor` fallback is dead unless the + * engine propagates it onto the hook session. + */ + describe('service-principal actor propagated to the hook session (#4366)', () => { + beforeEach(async () => { + engine.registerDriver(mockDriver, true); + await engine.init(); + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any); + }); + + it('surfaces context.actor as session.actor for a system write', async () => { + let session: any; + engine.registerHook('afterInsert', async (ctx: any) => { session = ctx.session; }, { object: 'task' }); + + await engine.insert('task', { title: 'x' }, { context: { isSystem: true, actor: 'svc:flow:mirror_status' } as any }); + + expect(session).toMatchObject({ isSystem: true, actor: 'svc:flow:mirror_status' }); + }); + + it('omits session.actor when the context carries none (no anonymous label invented)', async () => { + let session: any; + engine.registerHook('afterInsert', async (ctx: any) => { session = ctx.session; }, { object: 'task' }); + + await engine.insert('task', { title: 'y' }, { context: { userId: 'u1' } as any }); + + expect(session.userId).toBe('u1'); + expect(session.actor).toBeUndefined(); + }); + }); + describe('organizationId exposed to hooks as the blessed org name (#3280)', () => { beforeEach(async () => { engine.registerDriver(mockDriver, true); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 93bc39ff4b..d1e38cbd22 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -858,6 +858,13 @@ export class ObjectQL implements IDataEngine { // Propagate system-elevated flag so hooks can distinguish engine // self-writes (e.g. approval status mirror) from genuine user writes. ...((execCtx as any).isSystem ? { isSystem: true } : {}), + // Propagate the service-principal label (`ExecutionContext.actor`, + // e.g. `svc:flow:`) so a non-user write stays attributable in the + // audit log — the writer's `userId ?? session.actor` fallback is dead + // without this hop (ADR-0014 D2, #4366). + ...(typeof (execCtx as any).actor === 'string' && (execCtx as any).actor + ? { actor: (execCtx as any).actor } + : {}), // Propagate the automation-suppression flag so the record-change trigger // can skip flow dispatch for seed/bulk writes (ADR: seed loads end-state // data, not user events). `skipAutomations` implies `skipTriggers` — diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 8fc57809f2..ee49cf5845 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -1121,12 +1121,38 @@ describe('ApprovalService (node era)', () => { // ── thread interactions ───────────────────────────────────────── - it('reassign: hands the slot to a new approver and audits the move', async () => { + it('reassign: hands the slot to a new approver and audits the move on structured fields (#4365)', async () => { const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX); const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9')); expect(out.request.pending_approvers).toEqual(['u7', 'u2']); const actions = await svc.listActions(req.id, SYS); - expect(actions.at(-1)).toMatchObject({ action: 'reassign', actor_id: 'u9', comment: 'u9 → u7' }); + const audit = actions.at(-1)!; + expect(audit).toMatchObject({ action: 'reassign', actor_id: 'u9', reassign_from: 'u9', reassign_to: 'u7' }); + // No user comment → none invented. The old default baked raw user ids + // into user-facing text ("u9 → u7"). + expect(audit.comment).toBeUndefined(); + }); + + it('reassign: a user comment is stored verbatim alongside the structured fields (#4365)', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await svc.reassign(req.id, { actorId: 'u9', to: 'u7', comment: 'On leave next week' }, asUser('u9')); + const actions = await svc.listActions(req.id, SYS); + expect(actions.at(-1)).toMatchObject({ + action: 'reassign', reassign_from: 'u9', reassign_to: 'u7', comment: 'On leave next week', + }); + }); + + it('reassign: listActions resolves the hand-off parties to display names (#4365)', async () => { + engine._tables['sys_user'] = [ + { id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }, + { id: 'u7', name: 'Ada Lovelace', email: 'ada@example.com' }, + ]; + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9')); + const actions = await svc.listActions(req.id, SYS); + expect(actions.at(-1)).toMatchObject({ + reassign_from_name: 'Grace Hopper', reassign_to_name: 'Ada Lovelace', + }); }); it('reassign: notifies the new approver via messaging', async () => { diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 211882c579..4f7e3ce2e5 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -434,6 +434,9 @@ function rowFromAction(row: any): ApprovalActionRow { action: row.action, actor_id: row.actor_id ?? undefined, comment: row.comment ?? undefined, + // Structured reassign hand-off parties (#4365). + reassign_from: row.reassign_from ?? undefined, + reassign_to: row.reassign_to ?? undefined, // Decision attachments (#3266): rich descriptors carrying the display name + // download URL, so consumers label/open them without reading `sys_file`. attachments: attachments.length ? attachments : undefined, @@ -2276,7 +2279,11 @@ export class ApprovalService implements IApprovalService { await this.engine.insert('sys_approval_action', { id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null, step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'reassign', - actor_id: actorId, comment: input.comment ?? `${from} → ${to}`, created_at: now, + // The hand-off parties are STRUCTURED fields (#4365) — the old default + // comment (`""`) baked raw user ids into user-facing text. + // `comment` is pure user input: absent unless the actor wrote one. + actor_id: actorId, reassign_from: from, reassign_to: to, + comment: input.comment ?? null, created_at: now, }, { context: SYSTEM_CTX }); // per_group / quorum (#3266): carry the delegated slot's group membership to // the new approver in the snapshot, so their approval still counts for the @@ -3620,13 +3627,20 @@ export class ApprovalService implements IApprovalService { }); const actions = Array.isArray(rows) ? rows.map(rowFromAction) : []; // Timeline display: resolve actor ids to names so the audit trail never - // shows a raw identifier. Role/team literals are already readable. + // shows a raw identifier. Role/team literals are already readable. The + // reassign hand-off parties (#4365) resolve through the same batch. const names = await this.resolveUserNames( - actions.map(a => a.actor_id).filter(id => id && !id.includes(':')), + actions + .flatMap(a => [a.actor_id, a.reassign_from, a.reassign_to]) + .filter(id => id && !id.includes(':')), ); for (const a of actions as any[]) { const n = a.actor_id ? names.get(String(a.actor_id)) : undefined; if (n) a.actor_name = n; + const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : undefined; + if (fromName) a.reassign_from_name = fromName; + const toName = a.reassign_to ? names.get(String(a.reassign_to)) : undefined; + if (toName) a.reassign_to_name = toName; } return actions; } diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index df4152cabd..cd7a01cd49 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -119,6 +119,25 @@ export const SysApprovalAction = ObjectSchema.create({ comment: Field.textarea({ label: 'Comment', required: false, group: 'Action' }), + // Structured hand-off parties for `action: 'reassign'` (#4365). Before + // these existed the pair lived only inside a default free-text comment + // (""), which no client could parse or render readably. + // `comment` is pure user input again; timelines render "from A to B" from + // these fields. + reassign_from: Field.lookup('sys_user', { + label: 'Reassigned From', + required: false, + group: 'Action', + description: 'User whose pending-approver slot was handed over (reassign actions only)', + }), + + reassign_to: Field.lookup('sys_user', { + label: 'Reassigned To', + required: false, + group: 'Action', + description: 'User who received the pending-approver slot (reassign actions only)', + }), + attachments: Field.file({ label: 'Attachments', required: false, diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index e1b2180a35..6350e68439 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -235,6 +235,14 @@ export const enObjects: NonNullable = { comment: { label: "Comment" }, + reassign_from: { + label: "Reassigned From", + help: "User whose pending-approver slot was handed over (reassign actions only)" + }, + reassign_to: { + label: "Reassigned To", + help: "User who received the pending-approver slot (reassign actions only)" + }, attachments: { label: "Attachments", help: "Files supporting this action — e.g. a signed contract or evidence (#3266)." diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index 716dece0c7..807985fd52 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -235,6 +235,14 @@ export const esESObjects: NonNullable = { comment: { label: "Comentario" }, + reassign_from: { + label: "Reasignado de", + help: "Usuario cuyo turno de aprobación pendiente fue traspasado (solo acciones de reasignación)" + }, + reassign_to: { + label: "Reasignado a", + help: "Usuario que recibió el turno de aprobación pendiente (solo acciones de reasignación)" + }, attachments: { label: "Adjuntos", help: "Archivos que respaldan esta acción, p. ej. un contrato firmado o pruebas (#3266)." diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index 22e51489d2..144266bccf 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -235,6 +235,14 @@ export const jaJPObjects: NonNullable = { comment: { label: "コメント" }, + reassign_from: { + label: "引き継ぎ元", + help: "承認待ちスロットを引き渡したユーザー(引き継ぎ操作のみ)" + }, + reassign_to: { + label: "引き継ぎ先", + help: "承認待ちスロットを受け取ったユーザー(引き継ぎ操作のみ)" + }, attachments: { label: "添付ファイル", help: "この操作を裏付けるファイル——署名済み契約書や証憑など(#3266)。" diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index c2133b3a47..f48937c913 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -235,6 +235,14 @@ export const zhCNObjects: NonNullable = { comment: { label: "评论" }, + reassign_from: { + label: "转出人", + help: "被移交待审批槽位的用户(仅转签操作)" + }, + reassign_to: { + label: "转入人", + help: "接收待审批槽位的用户(仅转签操作)" + }, attachments: { label: "附件", help: "支持该操作的文件——例如已签署的合同或证明材料(#3266)。" diff --git a/packages/services/service-automation/src/builtin/crud-runas.test.ts b/packages/services/service-automation/src/builtin/crud-runas.test.ts index eaaa9ff77d..7617f40cbc 100644 --- a/packages/services/service-automation/src/builtin/crud-runas.test.ts +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -88,8 +88,11 @@ describe('flow.runAs identity enforcement at the data layer (#1888)', () => { for (const c of calls) { expect(c.ctx, `${c.op} got no context`).toBeTruthy(); expect(c.ctx.isSystem, `${c.op} not elevated`).toBe(true); - // An elevated run is NOT attributed to the triggering user. + // An elevated run is NOT attributed to the triggering user… expect(c.ctx.userId).toBeUndefined(); + // …but it IS attributed to the flow, so audit rows never read + // "Unknown user" (ADR-0014 D2, #4366). + expect(c.ctx.actor, `${c.op} lost the service-principal label`).toBe('svc:flow:sys'); } }); @@ -181,9 +184,15 @@ describe('flow.runAs identity enforcement at the data layer (#1888)', () => { }); describe('resolveRunDataContext (#1888 unit)', () => { - it("maps runAs:'system' to an elevated context", () => { - expect(resolveRunDataContext({ runAs: 'system', userId: 'u1' })).toEqual({ - isSystem: true, positions: [], permissions: [], + it("maps runAs:'system' to an elevated context attributed to the flow (#4366)", () => { + expect(resolveRunDataContext({ runAs: 'system', userId: 'u1', flowName: 'mirror_status' })).toEqual({ + isSystem: true, actor: 'svc:flow:mirror_status', positions: [], permissions: [], + }); + }); + + it("labels a system run without a flow name as the generic automation principal (#4366)", () => { + expect(resolveRunDataContext({ runAs: 'system' })).toEqual({ + isSystem: true, actor: 'svc:flow:automation', positions: [], permissions: [], }); }); @@ -226,7 +235,7 @@ describe('resolveRunDataContext (#1888 unit)', () => { it("refuses rather than elevating — runAs:'system' stays the only route to isSystem", () => { expect(() => resolveRunDataContext({ runAs: 'user', flowRunId: 'r' })).toThrow(); expect(resolveRunDataContext({ runAs: 'system', flowRunId: 'r' })).toEqual({ - isSystem: true, positions: [], permissions: [], flowRunId: 'r', + isSystem: true, actor: 'svc:flow:automation', positions: [], permissions: [], flowRunId: 'r', }); }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 9ab2adc742..5280b7c44e 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -1734,6 +1734,10 @@ export class AutomationEngine implements IAutomationService { const runContext: AutomationContext = { ...(context ?? {}), runAs: flow.runAs ?? 'user', + // `flowName` shares the same lifetime and construction point: it feeds + // audit attribution (`svc:flow:` on ExecutionContext.actor) for + // runs that resolve no user (#4366). + flowName: flow.name, ...(runId ? { flowRunId: runId } : {}), }; diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index ca15dd50c5..309f5b6012 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -14,6 +14,14 @@ import { markGuardRefusal } from './guard-refusal.js'; export interface RunIdentityContext { /** Elevated, RLS-bypassing system principal (full access) when true. */ isSystem: boolean; + /** + * Service-principal label for audit attribution (`ExecutionContext.actor`, + * ADR-0014 D2): `svc:flow:` on a `runAs:'system'` run, which + * resolves no user. Without it the audit writer records `user_id=null, + * actor=null` and the history UI renders "Unknown user" (#4366). + * Attribution only — no security middleware keys on it. + */ + actor?: string; /** Acting user id — drives owner/role RLS for `runAs:'user'` runs. */ userId?: string; /** Acting user's role names (RLS parity with a direct REST request). */ @@ -105,8 +113,10 @@ export type RunDataContext = RunIdentityContext | RunProvenanceContext; * Translate a flow run's {@link AutomationContext} into the ObjectQL `context` * its CRUD nodes must pass, honoring `runAs` (ADR-0049 / #1888): * - * - `runAs:'system'` → `{ isSystem: true }` — the security middleware - * short-circuits, so the run reads/writes with full access, bypassing RLS. + * - `runAs:'system'` → `{ isSystem: true, actor: 'svc:flow:' }` — + * the security middleware short-circuits, so the run reads/writes with full + * access, bypassing RLS; the `actor` label keeps those writes attributable + * in the audit log (ADR-0014 D2, #4366). * - `runAs:'user'` (default) → the triggering user's identity * (`{ userId, positions, permissions, tenantId? }`), so the security middleware * enforces that user's row-level security. The run can never exceed the @@ -139,7 +149,12 @@ export type RunDataContext = RunIdentityContext | RunProvenanceContext; export function resolveRunDataContext(context: AutomationContext | undefined): RunDataContext | undefined { const flowRunId = context?.flowRunId; if (context?.runAs === 'system') { - return { isSystem: true, positions: [], permissions: [], ...(flowRunId ? { flowRunId } : {}) }; + // A system run resolves no user, so name the flow as the acting service + // principal (`svc:flow:`, ADR-0014 D2) — the audit writer falls back + // to `session.actor` when `userId` is absent, which is what keeps these + // writes attributable instead of "Unknown user" (#4366). + const actor = `svc:flow:${context.flowName ?? 'automation'}`; + return { isSystem: true, actor, positions: [], permissions: [], ...(flowRunId ? { flowRunId } : {}) }; } if (!context?.userId) { // #3760 — FAIL CLOSED. There is no identity to present, and presenting none diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index dc23e030f6..5df42899d5 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -311,6 +311,21 @@ export interface ApprovalActionRow { created_at?: string; /** Display name of the actor (`sys_user.name`), when resolvable. */ actor_name?: string; + /** + * Structured hand-off parties on a `reassign` action (#4365): the user whose + * pending-approver slot was moved, and the user who received it. Previously + * the pair existed only inside a default free-text `comment` + * (`""`), which clients could neither parse nor render + * readably. `comment` is now pure user input; consumers render the hand-off + * from these fields (via the resolved `*_name` companions below). + */ + reassign_from?: string; + /** See {@link ApprovalActionRow.reassign_from}. */ + reassign_to?: string; + /** Display name of `reassign_from` (`sys_user.name`), when resolvable. */ + reassign_from_name?: string; + /** Display name of `reassign_to` (`sys_user.name`), when resolvable. */ + reassign_to_name?: string; } /** Input for a decision on an approval request. */ diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 170d1858b5..12a0688d0c 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -65,6 +65,18 @@ export interface AutomationContext { * Callers do NOT set this — the engine derives it from the flow definition. */ runAs?: 'system' | 'user'; + /** + * Machine name of the flow this run executes, stamped by the engine at run + * setup alongside {@link runAs} / {@link flowRunId} (same single + * construction point, same lifetime). Provenance, not authorization — no + * security middleware keys on it. Its consumer is audit attribution: a + * `runAs:'system'` run resolves no user, so `resolveRunDataContext` labels + * its data operations `svc:flow:` on `ExecutionContext.actor` + * (ADR-0014 D2) instead of leaving the audit row unattributed (#4366). + * + * Callers do NOT set this — the engine derives it, exactly like {@link runAs}. + */ + flowName?: string; /** * Id of the run this context belongs to, stamped by the engine at run setup * alongside {@link runAs} and carried into every data node's ObjectQL diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index cfcee9759a..d1a5438884 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -340,6 +340,15 @@ export const HookContextSchema = lazySchema(() => z.object({ */ session: z.object({ userId: z.string().optional(), + /** + * Service-principal label for audit attribution when the caller is not a + * real user (`ExecutionContext.actor`, ADR-0014 D2) — e.g. a service token + * (`svc:`) or an automation run (`svc:flow:`, #4366). + * `userId` takes precedence when set; this is the fallback the audit + * writer records on `sys_audit_log.actor`. Attribution only — it grants + * nothing and no security middleware keys on it. + */ + actor: z.string().optional().describe('Service-principal label for audit attribution when the caller is not a real user (e.g. svc:flow:)'), /** * Active organization ID — the developer-facing name for the caller's * current org. Same value everywhere a developer reads the org: the