Repository navigation
fix(plugin-approvals): every step of a multi-step approval names the run's submitter, not the previous step's approver - #24860
Conversation
…n's trigger user Reproduction first: on a real AutomationEngine + ApprovalService over SqlDriver, a later approval step opens with the previous step's decider as submitter_id. These pins go red on the unfixed approval node. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…user as its submitter Since a decision resumes its flow as the decider, the approval node's context.userId on a later step is the previous step's decider. The node now opens each step with the run's trigger user, the engine's own triggerUserId reading, and openNodeRequest treats an explicit null submitter as no submitter instead of falling back to the decider. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…ter-step-original-submitter
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d8320a16d266278d7ace5d6e58e953f6e441ce05 && git checkout d8320a16d266278d7ace5d6e58e953f6e441ce05
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7cacad5348d0f2665f1c069eccb5131bd955d768 bc2d3afd94e902bf97ac911df7607ab27943f34b && git checkout -B drift-repro 7cacad5348d0f2665f1c069eccb5131bd955d768 && git merge --no-ff bc2d3afd94e902bf97ac911df7607ab27943f34b
node scripts/docs-audit/affected-docs.mjs --json 7cacad5348d0f2665f1c069eccb5131bd955d768 |
席位核对 · ACCEPT ·
|
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 withcontext.userIdset to the DECIDER.approval-node.tsopened every approval step withsubmitterId: context?.userId ?? null, so every step after the first recorded the previous step's decider as itssubmitter_id. On every later step:listRequests({ submitterId })) listed the step under the previous approver;approval.returned,approval.request_info,approval.sla_breached,approval.approved,approval.rejected) went to the previous approver.The governing text is
ApprovalEscalationSchema.notifySubmitterinpackages/spec/src/automation/approval.zod.ts:742:Shipped flows on this path: the showcase's
showcase_budget_approval(manager_review, thenexec_reviewabove 500000) andshowcase_dynamic_approval(lead_review, thenco_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) recordstriggerUserIdon the switched context at the first decision switch, keeps it on every later switch, and persists it through each later pause;nullmeans the trigger resolved no user, and an absent key means no switch happened, souserIdis still the trigger user. The engine's own reader isrunTriggerUserId(runtime-identity.ts:188), whichbuildRunTriggeruses 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 (
seedRunVariablessets$record,record,previous,$runId,$flowNameand$flowLabelonly), 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:165readcontext?.userIdat41646a5788.What changes
packages/plugins/plugin-approvals/src/approval-node.ts— the producer. A new localrunTriggerUser(context)gives the engine's reading:triggerUserIdwhen the engine recorded it (nullstaysnull), elseuserId. It is mirrored here, not imported, because this plugin deliberately does not depend onservice-automation(the file'snestVariablesis the precedent). The node opens each step with it. The execution context handed toopenNodeRequestkeepsuserId: context?.userId, so the decider still acts there.packages/plugins/plugin-approvals/src/approval-service.ts,openNodeRequestonly (notremind()): an explicitsubmitterId: nullnow means no submitter. Before,nullfell back tocontext.userIdlike 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 omittedsubmitterIdstill falls back. This file is outside the claim's declared surface; it is declared here. The hunks (~:3453, ~:3740, ~:3773) are disjoint fromremind()and from the ranges other open work edits..changeset/24817-later-step-original-submitter.md—patchfor@objectstack/plugin-approvals. It says this corrects the unreleased later-step submitter that PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527 introduced, and that compared with the last release nothing moves.What follows
submitter_idby design moves with it on later steps, back to its released-18.0 value: the step'ssubmittimeline row (actor_id), and the attribution of thependingstatus mirror written when the step opens. The latter is attributed to whoever the row calls the submitter (approval-service.ts, the comment abovemirrorStatusField). 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 realObjectQLoverSqlDriver(better-sqlite3, in-memory, real DDL), a realAutomationEnginewith the built-in logic nodes, a realApprovalService, and the real approval and revise nodes, built the wayapproval-decision-outcome-notification.integration.test.tsbuilds its stack. Eight cases:deal_chain(runAsdefault): steps 1, 2 and 3 each havesubmitter_id= the trigger user, and so does each step'ssubmitrow. 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.deal_chain_system(runAs: 'system'): the same.exec, andcfostill names the trigger user.FORBIDDENand the request stayspending. Then the original submitter is admitted and the request isrecalled.approval.returnedreaches the original submitter. The step-1 decider's resubmit is refused withFORBIDDEN, the request staysreturnedand no round opens. Then the original submitter is admitted, and round 2 opens on the same run naming them.approval.approvedto the original submitter, and step 1 sends nothing (the notice from PR fix(plugin-approvals): the decision that ends an approval tells its submitter the outcome #24816).Refusals are asserted on their service-seam ADR-0112 form, the
FORBIDDEN:code prefix that@objectstack/rest'shandleApprovalErrormaps tocode: 'FORBIDDEN'and status 403. That mapping is pinned live inpackages/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)e3ac5acecd:pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/later-step-original-submitter.integration.test.tsgaveTests 7 failed | 1 passed (8). Step 2 readexpected 'u_mgr' to be 'u_sub', and the recall case readexpected 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.bc2d3afd94(after mergingorigin/mainb3fc122f07):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, includingcheck:test-typecheck: OK … 8 file(s) / 324 error(s) / 27 pinned signature(s) held. The new file adds no debt.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 withgit checkout HEAD -- PATH. The restore was proven by blob equality withHEAD:PATHand an emptygit diff HEAD. The tests import./approval-node.jsand./approval-service.jsfrom source, so nodist/is involved. All three legs ran atbbbbbe4590.approval-node.ts:submitterId: runTriggerUser(context),→submitterId: context?.userId ?? null,(blobe972e15b→24a2845b)7 failed / 1 passed: every later-step case red, the one-step control greene972e15b== HEAD,git diff HEADemptyapproval-service.ts: the explicit-null line →input.submitterId ?? context.userId ?? null(blob02fd843f→d282e59d)1 failed / 7 passed: only the user-less case red02fd843f== HEAD,git diff HEADemptyapproval-node.ts: a recordednulltrigger read astriggerUserId ?? userId(blobe972e15b→916125e4)1 failed / 7 passed: only the user-less case rede972e15b== HEAD,git diff HEADemptyLeg 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/objectstackatbc2d3afd94: 70 commands. The local run and the--ranreconciliation are recorded in theos-dev-reportcomment 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
triggerUserIdreading inside the approval node. The options not taken: declaretriggerUserIdon spec'sAutomationContext(that ispackages/spec, a hard stop for this card), giveplugin-approvalsa runtime dependency onservice-automation, or look up step 1's request on the sameflow_run_id.runTriggerUserId. If the engine renames or reshapes the key, the real-engine pins turn red.??) is named in the code and pinned by leg C.Acceptance notes
triggerUserIdis declared onservice-automation's internalSeededRunContext, and spec'sAutomationContextdoes 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.@objectstack/restis not a dependency ofplugin-approvals, and the unaliased-import ledger inscripts/check-test-source-alias.mjsis shrink-only.origin/mainmoved toabd5acdaf7(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.mdis still pending) and #24321 (its ruling, not reopened here).Generated by Claude Code