Skip to content

Commit 28ff8a9

Browse files
fix(plugin-approvals): every step of a multi-step approval names the run's submitter, not the previous step's approver (#24860)
Fixes #24817 Clause-②: no ## What was wrong Since an approval decision resumes its flow as the person who decided (`ResumeSignal.resumedBy`; the resume-as-decider change of PR #24527, still unreleased), the segment after a decision runs with `context.userId` set to the DECIDER. `approval-node.ts` opened every approval step with `submitterId: context?.userId ?? null`, so every step after the first recorded the previous step's decider as its `submitter_id`. On every later step: - the "I Submitted" filter (`listRequests({ submitterId })`) listed the step under the previous approver; - recall and resubmit, which are submitter-only, admitted the previous approver and refused the real submitter; - every notice meant for the submitter (`approval.returned`, `approval.request_info`, `approval.sla_breached`, `approval.approved`, `approval.rejected`) went to the previous approver. The governing text is `ApprovalEscalationSchema.notifySubmitter` in `packages/spec/src/automation/approval.zod.ts:742`: > `notifySubmitter: z.boolean().default(true).describe('Notify the original submitter on escalation')` Shipped flows on this path: the showcase's `showcase_budget_approval` (`manager_review`, then `exec_review` above 500000) and `showcase_dynamic_approval` (`lead_review`, then `co_sign`). ## The rule Every step of a run records the run's ORIGINAL submitter, the user who triggered the run, as `submitter_id`, whoever resumed the run onto that step. The decider stays the actor of the resumed segment: the ruling behind the resume-as-decider change is not reopened, and nothing here changes the context a segment runs with. ## Where the original submitter comes from (mechanism assumption H2, measured) The engine already owns "who started this run". `AutomationEngine.resolveDecisionSegmentContext` (`packages/services/service-automation/src/engine.ts:6623`) records `triggerUserId` on the switched context at the first decision switch, keeps it on every later switch, and persists it through each later pause; `null` means the trigger resolved no user, and an absent key means no switch happened, so `userId` is still the trigger user. The engine's own reader is `runTriggerUserId` (`runtime-identity.ts:188`), which `buildRunTrigger` uses for the run history. That context is the one every node executor receives (`executor.execute(node, variables, context)`, `engine.ts:11958`). The two other candidates were not needed: the engine seeds no flow variable for the trigger user (`seedRunVariables` sets `$record`, `record`, `previous`, `$runId`, `$flowName` and `$flowLabel` only), and a lookup of step 1's request row would be a sibling read where the engine already answers. Mechanism assumption H1 held: `approval-node.ts:165` read `context?.userId` at `41646a5788`. ## What changes - **`packages/plugins/plugin-approvals/src/approval-node.ts`** — the producer. A new local `runTriggerUser(context)` gives the engine's reading: `triggerUserId` when the engine recorded it (`null` stays `null`), else `userId`. It is mirrored here, not imported, because this plugin deliberately does not depend on `service-automation` (the file's `nestVariables` is the precedent). The node opens each step with it. The execution context handed to `openNodeRequest` keeps `userId: context?.userId`, so the decider still acts there. - **`packages/plugins/plugin-approvals/src/approval-service.ts`, `openNodeRequest` only** (not `remind()`): an explicit `submitterId: null` now means no submitter. Before, `null` fell back to `context.userId` like an omitted value. On a later step of a run whose trigger resolved no user, that fallback named the decider as submitter even with the node fixed. An omitted `submitterId` still falls back. This file is outside the claim's declared surface; it is declared here. The hunks (~:3453, ~:3740, ~:3773) are disjoint from `remind()` and from the ranges other open work edits. - **`.changeset/24817-later-step-original-submitter.md`** — `patch` for `@objectstack/plugin-approvals`. It says this corrects the unreleased later-step submitter that PR #24527 introduced, and that compared with the last release nothing moves. What follows `submitter_id` by design moves with it on later steps, back to its released-18.0 value: the step's `submit` timeline row (`actor_id`), and the attribution of the `pending` status mirror written when the step opens. The latter is attributed to whoever the row calls the submitter (`approval-service.ts`, the comment above `mirrorStatusField`). The flow's own nodes after a decision still run as the decider. ## Tests New file `packages/plugins/plugin-approvals/src/later-step-original-submitter.integration.test.ts`. It uses a real `ObjectQL` over `SqlDriver` (better-sqlite3, in-memory, real DDL), a real `AutomationEngine` with the built-in logic nodes, a real `ApprovalService`, and the real approval and revise nodes, built the way `approval-decision-outcome-notification.integration.test.ts` builds its stack. Eight cases: 1. `deal_chain` (`runAs` default): steps 1, 2 and 3 each have `submitter_id` = the trigger user, and so does each step's `submit` row. Steps 2 and 3 open after decisions by two different people. The "I Submitted" filter finds all three steps under the trigger user and none under either decider. 2. `deal_chain_system` (`runAs: 'system'`): the same. 3. A gateway-skipped step: the decision node routes around `exec`, and `cfo` still names the trigger user. 4. A run whose trigger resolved no user: step 2 has no submitter, never the decider. 5. Recall on step 2: the step-1 decider is refused with code `FORBIDDEN` and the request stays `pending`. Then the original submitter is admitted and the request is `recalled`. 6. Resubmit on step 2, after a send-back from step 2: the send-back's `approval.returned` reaches the original submitter. The step-1 decider's resubmit is refused with `FORBIDDEN`, the request stays `returned` and no round opens. Then the original submitter is admitted, and round 2 opens on the same run naming them. 7. The decision that ends a two-step run sends `approval.approved` to the original submitter, and step 1 sends nothing (the notice from PR #24816). 8. Control: a one-step run names its trigger user, refuses another user's recall and admits the submitter's. Refusals are asserted on their service-seam ADR-0112 form, the `FORBIDDEN:` code prefix that `@objectstack/rest`'s `handleApprovalError` maps to `code: 'FORBIDDEN'` and status 403. That mapping is pinned live in `packages/rest/src/rest-approvals-wire-codes.test.ts`. Each refusal has its admitted control on the same request. ### Runs (all under `scripts/pm/os-verify-lock.sh`) - **Reproduction, before the fix, at `e3ac5acecd`**: `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/later-step-original-submitter.integration.test.ts` gave `Tests 7 failed | 1 passed (8)`. Step 2 read `expected 'u_mgr' to be 'u_sub'`, and the recall case read `expected undefined to be 'FORBIDDEN'`: the step-1 decider's recall was admitted. The one-step control passed. The user-less case failed on a test-helper default-parameter slip, which was corrected at the next commit; its red under the old code is shown by ablation legs B and C below. - **Final head `bc2d3afd94`** (after merging `origin/main` `b3fc122f07`): - `pnpm --filter '@objectstack/plugin-approvals^...' build`: VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2`: `Test Files 89 passed (89)`, `Tests 1172 passed (1172)`. - `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0, including `check:test-typecheck: OK … 8 file(s) / 324 error(s) / 27 pinned signature(s) held`. The new file adds no debt. - The new file and `approval-decision-outcome-notification.integration.test.ts`, verbose: `Tests 16 passed (16)`. ### Ablation (one-time proof; nothing left in the tree) Each leg was mutated through `node scripts/ablation-replace.mjs`, so the anchor had to hit once and the write was verified on disk. Each leg ran the new file and was restored with `git checkout HEAD -- PATH`. The restore was proven by blob equality with `HEAD:PATH` and an empty `git diff HEAD`. The tests import `./approval-node.js` and `./approval-service.js` from source, so no `dist/` is involved. All three legs ran at `bbbbbe4590`. | leg | mutation | result | restore | |:--|:--|:--|:--| | A | `approval-node.ts`: `submitterId: runTriggerUser(context),` → `submitterId: context?.userId ?? null,` (blob `e972e15b` → `24a2845b`) | `7 failed / 1 passed`: every later-step case red, the one-step control green | blob `e972e15b` == HEAD, `git diff HEAD` empty | | B | `approval-service.ts`: the explicit-null line → `input.submitterId ?? context.userId ?? null` (blob `02fd843f` → `d282e59d`) | `1 failed / 7 passed`: only the user-less case red | blob `02fd843f` == HEAD, `git diff HEAD` empty | | C | `approval-node.ts`: a recorded `null` trigger read as `triggerUserId ?? userId` (blob `e972e15b` → `916125e4`) | `1 failed / 7 passed`: only the user-less case red | blob `e972e15b` == HEAD, `git diff HEAD` empty | Leg C is why the reader is not `triggerUserId ?? userId`: under that reading, a run nobody triggered names its first decider as submitter. ## Gates Gates are derived from this diff by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `bc2d3afd94`: 70 commands. The local run and the `--ran` reconciliation are recorded in the `os-dev-report` comment on the card. CI's check runs on this PR are the record for every family CI runs. ## Route chosen, read on the four axes The option taken is to mirror the engine's own `triggerUserId` reading inside the approval node. The options not taken: declare `triggerUserId` on spec's `AutomationContext` (that is `packages/spec`, a hard stop for this card), give `plugin-approvals` a runtime dependency on `service-automation`, or look up step 1's request on the same `flow_run_id`. - **Real business need.** Measured: the two showcase multi-step flows, and every multi-step approval after the resume-as-decider change. - **Long-term soundness.** The source is the one the engine owns and already persists, and no stored key or column is added. The mirror is one function documented against `runTriggerUserId`. If the engine renames or reshapes the key, the real-engine pins turn red. - **Making AI mistakes structurally harder.** No authoring surface moves. The one tempting misreading (`??`) is named in the code and pinned by leg C. - **Startup-stage focus.** No new capability, key, gate or dependency. ## Acceptance notes - `triggerUserId` is declared on `service-automation`'s internal `SeededRunContext`, and spec's `AutomationContext` does not declare it, although every node executor receives it. A plugin that wants "who started this run" therefore mirrors the engine's reader, as this one now does. Not filed: no second consumer exists. Carrier: none. - The 403 half of the refusal envelope is asserted at the REST door, not in this package: `@objectstack/rest` is not a dependency of `plugin-approvals`, and the unaliased-import ledger in `scripts/check-test-source-alias.mjs` is shrink-only. - `origin/main` moved to `abd5acdaf7` (plugin-email and service-sms test-send recipients) after the merge. It touches none of this diff's packages. Related: PR #24527 (the resume-as-decider change this corrects; its changeset `.changeset/24321-resume-as-decider.md` is still pending) and #24321 (its ruling, not reopened here). --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 06cab48 commit 28ff8a9

4 files changed

Lines changed: 438 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): every step of a multi-step approval names the person who submitted the run, not the previous step's approver
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** This corrects the unreleased later-step submitter that PR #24527 introduced (an approval decision resumes its flow as the person who decided, `.changeset/24321-resume-as-decider.md`). Because the nodes after a decision now run as the decider, an approval step that opened after another step's decision recorded that decider as its `submitter_id`. On every later step the "I Submitted" list showed the request to the previous approver, recall and resubmit admitted the previous approver and refused the real submitter, and every notice meant for the submitter (`approval.returned`, `approval.request_info`, `approval.sla_breached`, `approval.approved`, `approval.rejected`) went to the previous approver.
10+
- **Now.** Every step of a run records the user who triggered the run as its `submitter_id`, and as the actor of the step's `submit` timeline entry, whoever decided the steps before it. That holds on both `runAs` postures, after any number of decisions by different people, and on a step that a decision node reaches by routing around another. A run whose trigger resolved no user (a schedule, a write that carried no user) opens its later steps with no submitter, as it opens its first one.
11+
- **Unchanged.** The decider still runs the nodes after their decision: their identity, permissions and attribution, as the resume-as-decider change ships them. The first step of every run, and every single-step approval, are unchanged. Compared with the last release nothing moves: later steps named the run's submitter there too, so recall and resubmit on a later step go back to the person they reached before.
12+
- **`ApprovalService.openNodeRequest`.** A caller that passes `submitterId: null` now opens the request with no submitter. Before, `null` fell back to the calling context's user, the same as leaving `submitterId` out, which still falls back.

‎packages/plugins/plugin-approvals/src/approval-node.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,34 @@ function nestVariables(variables: Map<string, unknown>): Record<string, unknown>
8383
return vars;
8484
}
8585

86+
/**
87+
* The user who TRIGGERED the run — every step's submitter, whoever resumed the
88+
* run onto it (`ApprovalEscalationSchema.notifySubmitter`: "Notify the
89+
* original submitter on escalation").
90+
*
91+
* After an approval decision the engine runs the next segment as the DECIDER
92+
* (`ResumeSignal.resumedBy`, ruling A on `FlowSchema.runAs`), so
93+
* `context.userId` on a later step is the previous step's decider, never the
94+
* submitter. The engine keeps who started the run on the same context: at the
95+
* first switch it records `triggerUserId` (`null` when the trigger resolved no
96+
* user) and every later switch keeps it, persisted through each pause. Absent,
97+
* no switch happened and `userId` is still the trigger user. This is the
98+
* engine's own reading, `runTriggerUserId` in `@objectstack/service-automation`'s
99+
* `runtime-identity.ts`, mirrored here because this plugin deliberately does not
100+
* depend on service-automation (as {@link nestVariables} mirrors its condition
101+
* scope). The two-step pins in `later-step-original-submitter.integration.test.ts`
102+
* run it against the real engine.
103+
*
104+
* ⛔ Not `triggerUserId ?? userId`: a recorded `null` means nobody triggered
105+
* the run, and falling through would name the decider as its submitter.
106+
*/
107+
function runTriggerUser(context: { userId?: string; triggerUserId?: unknown } | undefined): string | null {
108+
if (context && Object.prototype.hasOwnProperty.call(context, 'triggerUserId')) {
109+
return typeof context.triggerUserId === 'string' && context.triggerUserId !== '' ? context.triggerUserId : null;
110+
}
111+
return context?.userId ?? null;
112+
}
113+
86114
/**
87115
* Register the `approval` node executor on the automation engine, plus the
88116
* `approval_revise` window the ADR-0044 send-back parks on. Idempotent at the
@@ -162,7 +190,10 @@ export function registerApprovalNode(
162190
flowName,
163191
flowLabel,
164192
nodeLabel: typeof node.label === 'string' ? node.label : undefined,
165-
submitterId: context?.userId ?? null,
193+
// The run's trigger user, not `context.userId`: after a decision
194+
// that is the decider, who acts in this segment (the envelope below)
195+
// but did not submit the run.
196+
submitterId: runTriggerUser(context),
166197
record,
167198
organizationId: context?.organizationId ?? context?.tenantId ?? null,
168199
// #3447 P2: flow variables (nested, as CEL conditions see them) — the

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3445,6 +3445,13 @@ export class ApprovalService implements IApprovalService {
34453445
flowLabel?: string;
34463446
/** Authored node label, snapshotted for inbox display. */
34473447
nodeLabel?: string;
3448+
/**
3449+
* Who submitted the run this step belongs to — the approval node passes
3450+
* its trigger user. `null` is an answer: the trigger resolved no user, so
3451+
* the step has no submitter. Only an OMITTED value falls back to the
3452+
* acting context's user: on a later step that context is the previous
3453+
* step's decider, who did not submit the run.
3454+
*/
34483455
submitterId?: string | null;
34493456
record?: any;
34503457
organizationId?: string | null;
@@ -3725,12 +3732,13 @@ export class ApprovalService implements IApprovalService {
37253732
const n = Array.isArray(prior) ? prior.length : 0;
37263733
if (n > 0) configSnapshot.__round = n + 1;
37273734
} catch { /* round display is best-effort */ }
3735+
const submitterId = input.submitterId !== undefined ? input.submitterId : (context.userId ?? null);
37283736
const row: any = {
37293737
id,
37303738
process_name: processName,
37313739
object_name: input.object,
37323740
record_id: input.recordId,
3733-
submitter_id: input.submitterId ?? context.userId ?? null,
3741+
submitter_id: submitterId,
37343742
status: 'pending',
37353743
// [#23889] The display snapshots the approval lists show: the flow's and
37363744
// the node's authored labels as the flow engine handed them over, and
@@ -3757,7 +3765,7 @@ export class ApprovalService implements IApprovalService {
37573765
await this.engine.insert('sys_approval_action', await this.actionRow(row, {
37583766
id: uid('aact'), request_id: id, organization_id: requestOrg,
37593767
step_name: input.nodeId, step_index: 0, action: 'submit',
3760-
actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now,
3768+
actor_id: submitterId, comment: null, created_at: now,
37613769
}), { context: SYSTEM_CTX });
37623770

37633771
// [#22824] The request's display says why it is with the administrators:

0 commit comments

Comments
 (0)