Repository navigation
feat(automation)!: an approval decision resumes a flow as the person who decided - #24527
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…as 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 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 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 df62bbd6883aedb7a97d1f52e7c8758e90514441 && git checkout df62bbd6883aedb7a97d1f52e7c8758e90514441
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2667e2402c48e9201550caca2adbcf9188d925b3 e5150923a095f4f77dcbc60486d1594fd2541a63 && git checkout -B drift-repro 2667e2402c48e9201550caca2adbcf9188d925b3 && git merge --no-ff e5150923a095f4f77dcbc60486d1594fd2541a63
node scripts/docs-audit/affected-docs.mjs --json 2667e2402c48e9201550caca2adbcf9188d925b3
|
…ider
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 <noreply@anthropic.com>
…sion 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 <noreply@anthropic.com>
… ruling 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 <noreply@anthropic.com>
runAs: 'user' flow as the person who decided
Contract reviewServed-tier: Read at 2026-10-19T14:47Z on PR #24527 (draft, head repo = base repo), card #24321 (body and all twelve comments: ruling A ① Derived judgmentsEach judged against the code at the head, not against the reports.
② Semver levelRight. The changeset bumps exactly the three packages whose published source moves — ③ Boundary flagsDev flags (
The claim's exclusions hold on the diff: Found by this review — the one defect, and the reason for the verdict. Every contract-level judgment above is right. The next head needs only the docs edit; ① and ② carry over to it unchanged unless the engine, approvals or spec files move. Implemented-by: VERDICT: FAIL Generated by Claude Code |
REWORK · patch round 2 ·
|
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 <noreply@anthropic.com>
Contract reviewServed-tier: Read at 2026-10-19T16:15Z, the re-review after FAIL What moved since ① Derived judgmentsCarried over unchanged (the code is blob-identical), each right:
Judged new on this head — the two rewritten sentences, against the code: right.
② Semver levelCarried over, right: the changeset is blob-identical — ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · The contract review of record is PASS
Generated by Claude Code |
Fixes #24321
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 theresumedByfield onApprovalResumeSurface's resume signal and onStrandedContinuationSignal(@objectstack/plugin-approvals). No schema shape moves:flow.runAskeeps its two values and only its describe changes.After an approval decision (approve or reject), the flow's following nodes run as the person who decided: ruling A (comment 6257886738), with item 2 read on its measured population by ruling 6267719791 (Q1 A, Q2 A).
What changes
runAs: 'user': identity and permissions together. On resume,resolveDecisionSegmentContextbuilds the context the nodes after the pause run with. When the service-stamped signal names a decider (resumedBy), the context is a copy of the persisted one with the trigger user'spositions,permissionsandorg_user_idsremoved,userIdset to the decider, and the decider's own grants resolved byseedRunGrants. That is the trigger setup's resolver from PR fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495, now shared by both callers.runAs: 'system': keeps system privileges, and the attribution user becomes the decider (ruling 6267719791 Q1 A). The nodes after the decision run as{ isSystem: true, userId: DECIDER }, the shape the approvals verdict mirror already writes with. No grant is resolved and elevation is unchanged, so areadonlyverdict column still lands. The trigger user's grants are removed here too, socurrent_userin the segment is the decider.updated_by,sys_audit_logandctx.userin the hooks those writes fire name the decider.SeededRunContext.triggerUserIdkeeps the run's trigger, so the run history (buildRunTrigger) still names who started it.@objectstack/plugin-approvals.decidehandsresumedBy: actingUserId(context)to the engine, the person the verdict mirror already writes as. The SLA sweep'sSYSTEM_CTXvouches for nobody, so its auto-decision names no decider and the run keeps its identity. A stranded decision journals its decider, andcontinueRestoredRunreplays it as them. The row builders (plugin-approvals: the request and action list views show an Object column with the target object's API name (object_name, such asshowcase_announcement), the type half of the record pointer pair, drawn as plain text #24501's region; plugin-approvals: the hand-built contexts its writes run under don't fully carry the deciding user's context, so a runAs:'user' hook they fire gets neither the user's organisation peers nor the user's own grants (the producer half of #24497) #24519 now queued on them) and the hand-built{ isSystem: true, userId }write contexts are untouched.RESUME_AUTHORITY_SERVICE. The REST resume body is also a closed key set (inputs,variables,output,branchLabel), so an HTTP caller cannot send a decider at all.Measurement (ruling A item 5)
HotCRM's four branches: who holds the write each post-decision node makes
Read only,
objectstack-ai/hotcrmat296167936b06(pinned to@objectstack/*18.0.0-next.3). Rep =sales_rep(src/sales/profiles/sales-rep.profile.ts); manager =sales_manager(src/sales/profiles/sales-manager.profile.ts). Object edit and row policy are the same on every row: the manager holdscrm_opportunity: { allowEdit, viewAllRecords, modifyAllRecords }(sales-manager.profile.ts:22) under theselectrow policyopportunity_private_owner_only(:117–:127,is_private == false || owner_id == current_user.id); the rep holdsallowEditwithreadScope: 'own'(sales-rep.profile.ts:52). Neither set carries a field entry for any column below.opportunity_approvalapprovemark_approvedapproval_status,approved_datereadonly: true(opportunity.object.ts:485,:498)opportunity_status_change_approvalapproveapply_statusstage,status_change_approval_status,approved_datestageyes; the other two no:readonly: true(:549,:498)stageyes on own deals; the other two noopportunity_status_change_approvalrejectclear_requeststatus_change_approval_status,requested_statusrequested_statusyes; the verdict no:readonly: true(:549)requested_statusyes on own; the verdict noopportunity_qualification_approvalrejectclear_requestqualification_approval_status,qualification_requestedqualification_requestedyes; the verdict no:readonly: true(:215)qualification_requestedyes on own; the verdict noEvery one of these nodes writes a
readonly: truecolumn, which objectql strips from every non-system UPDATE (packages/objectql/src/engine.ts, theif (!opCtx.context?.isSystem)branch of the UPDATE path). So all four branches need elevation, and all four flows already declarerunAs: 'system'(opportunity-approval.flow.ts:57,opportunity-status-change-approval.flow.ts:70,opportunity-qualification-approval.flow.ts:72). So do the other two HotCRM approval flows (account-approval.flow.ts:75,lead-conversion-approval.flow.ts:66). Recorded per item 3 as the pull for a finer, per-node elevation: the ordinary fields (stage,requested_status,qualification_requested) could then go through the manager's own rights while the verdict and stamp columns land as platform writes.Under ruling 6267719791 Q1 A, this PR now reaches all four branches. They keep their elevation, and their post-decision writes name the manager (next table). The in-repo
showcase_invoice_signoff(runAs: 'system', a post-decisionupdate_record) is met with them.After the change: attribution, and the run's report when a right is missing
Measured through the real doors, on the harness HotCRM's own suites boot (
@objectstack/verify'sbootStack,requires: ['automation', 'triggers', 'approvals', 'audit', 'sharing']). The fixture mirrors HotCRM's shapes: aprivatedeal object with HotCRM'sreadonlyapproval_statusandapproved_datecolumns; a rep set (allowEdit,readScope: 'own'); a manager set (allowEdit,viewAllRecords,modifyAllRecords, withrestricted_notenon-editable); arecord-after-createflow with a position-routedapprovalnode and a post-decisionupdate_recordon each branch. The rep creates the deal withPOST /api/v1/data/OBJECT; the manager decides withPOST /api/v1/approvals/requests/ID/approveor/reject. It was a one-off measurement file, deleted after each run; the permanent pins are listed under Tests.runAs)updated_bybefore → aftersys_audit_logupdate rowsystem(HotCRM shape){approval_status, approved_date}by managerapproval_status: approved,approved_datesetcompletedsystem(HotCRM shape){requested_status, approval_status}by managerapproval_status: rejectedcompleteduser{approved_note}by managercompleteduser{requested_status}by managercompleteduser, the manager lacks the fieldfailedatpost_approve:[Security] Field write denied: not permitted to edit [restricted_note] on 'OBJECT'RESUME_FAILED,repairable: true, naming the request, the run, the node and the denialAll five rows were measured at
c17394ee5, the final head. The rep's owncreaterow stays theirs in every case. A pre-decision flow node keeps the submitter too: in the service-automation real-stack test, thesubmitnode leavesupdated_byand its audit row on the rep, on both postures.Rulings
Open questions from the first round are ruled:
6267719791, Q1 A, Q2 A (maintainer 「同意」 2026-10-19T12:55Z). Q1 A: arunAs: 'system'flow keeps system privileges, and after the decision its attribution user is the decider. Q2 A: send-back, resubmit and recall keep item 1 as written.Tests
At
c17394ee5:packages/services/service-automation/src/resume-decider-identity.integration.test.ts(new, 21 pins; 1.32 s of test time).resolveRunDataContext(context).runAs: 'user'approve and reject carry the decider's resolved grants and none of the submitter's. A later pause keeps the decider; a second decision moves to its own decider while the run history keeps the trigger; a decider whose grants fail to resolve runs on the baseline.runAs: 'system': approve and reject carry{ isSystem: true, userId: usr_mgr, positions: [], permissions: [] }with no grant resolved and the forwarded grants removed, and the run history keeps the trigger.runAs: 'user':updated_byand thesys_audit_logrow name the manager after and the rep before; a guard shows the manager's write rests on the manager's own sets; an FLS-denied post-decision node is refused as a repairable strand naming the node and field, while the same node with no decider lands as the rep.runAs: 'system': a guard shows thereadonlyverdictcolumn is stripped from the manager's own request; approve and reject landverdictunder the unchanged elevation, withupdated_byand the audit row naming the manager; with no decider, the rep.packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts(one describe, 6 pins, on the file's existing pinned engine double, through the realApprovalServiceandapprovalnode).decideapprove and reject run arunAs: 'user'flow's post-decision nodes as the decider with the decider's grants, and arunAs: 'system'flow's as{ isSystem: true, userId: u1 }. The SLA-shaped decision keeps the run's identity. A stranded decision journalsresumedBy: 'u1', andcontinueRestoredRunreplays as the decider.c17394ee5(after mergingorigin/mainto04669a467):@objectstack/service-automationpnpm test192 files / 2567 passed;@objectstack/plugin-approvalspnpm test81 / 1118 passed;@objectstack/specpnpm test701 / 21311 passed (1 todo).typecheckis green for all three (check:test-typecheckOK, debt ledgers unchanged).packages/services/service-automation/package.jsongains@objectstack/plugin-sharingand@objectstack/plugin-auditas devDependencies (lockfile: 6 lines), each aliased to source invitest.config.ts. Aprivateobject's write verdict belongs to record sharing, and the audit row is the audit plugin's.Ablation (one-off, not kept)
Both runs went through
scripts/ablation-replace.mjsonengine.ts. The suite imports the engine relatively, from source, so no build or dist leg applies. Each direction was predicted before its run.undefined(blob7172068a6966→6dfa06c97035). Predicted red on the decider pins; observed 8 failed / 6 passed of the 14 then present. Restored:blob == HEAD,git diff HEADempty.|| context?.runAs === 'system'was added to the early return (anchor hit 1 → 0; blob019ddb6c6fd5→0a62cc66e57a). Predicted red on exactly the four system attribution pins; observed 4 failed / 17 passed. Failed: envelope system approve and reject, and real-stack system approve and reject. The system no-decider controls and the history pin stayed green. Restored:blob == HEAD 019ddb6c6fd5,git diff HEADempty.Gates
Local, at
c17394ee5, after the final commit:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 127 families over this diff. All 127 were run, each recorded:: exit 0.--ranreconciliation: 127 derived, 127 run, 0 NOT-MEASURED, 0 UNRUN. The local-onlycheck:dev-prereqsis OK. The changeset gates on the new text:check-adr-0087-registration --base origin/mainOK (not-required (no-migration-prescription), read as BREAKING + bang + clause-② narrowing), withcheck-changeset-no-majorandcheck-empty-changesetOK. An earlier start of this round's union was stopped and re-run from scratch afterpackages/specwas rebuilt: a comment-only edit had left itsdiststale, and the fresh worktree lacked 21 package builds. CI's check-runs on this head are the seat's to read.Round 2 (contract review
6270059532, rework6270117310), heade5150923a. This round changes no code: two sentences ofcontent/docs/automation/flows.mdx, and the decider pin file's header comment, which now namesrestored-approval-continuation.test.ts.origin/mainwas merged to2667e2402(e5150923a, viaos-regen-merge.sh); its six commits touch no package this PR changes. The derived list was re-derived on the final diff (127 families) and run in full: every family:: exit 0, and--rangives 127 derived, 127 run, 0 NOT-MEASURED, 0 UNRUN.check:dev-prereqsandcheck-adr-0087-registration --base origin/mainare OK. The decider pin file runs 21/21 ate5150923a. The full package suites were last run atc17394ee5, as listed above.Spec-lane files
Declared to the spec seat (
contracts/**included, per ruling 6267719791 item 3):packages/spec/src/automation/flow.zod.ts: therunAsdescribe and its comment (ruling A item 2's sentence, and item 2's new reading for'system').packages/spec/src/contracts/automation-service.ts:ResumeSignal.resumedBy(new optional member, both postures in its TSDoc); the TSDoc ofAutomationContext.userId; and the parity sentence underAutomationContext.flowName, which now says the acting user moves to the decider on both postures.pnpm --filter @objectstack/spec check:generated --fix(gen:docs, the one artefact it proved stale):content/docs/references/automation/flow.mdx,content/docs/references/api/automation-api.mdx.Acceptance notes
resumedBy, so its repair replay continues as the trigger user. A run that paused before this release and is decided after it does switch. Carrier: none.docs/adrsearched: ADR-0019, ADR-0044, ADR-0073, ADR-0118). ADR-0073 D2 (posture is not attribution) is the closest, and the system posture now follows it: elevation is posture, the decider is attribution. Per item 6 no ADR line is owed, and none was written.content/docs/automation/flows.mdx) now says what the describe says, after contract review6270059532found two sentences this PR had made false (rework6270117310). TherunAsrow reads:'system'is elevated, and after an approval decision its attribution user is the decider;'user'is the trigger user until a human decision and the decider after it. The "Who may resume" paragraph reads: a resumed run continues under the identity stored on the run, the user who started it or, after an approval decision, the decider. The caller gate after it (trigger.userId) is unchanged.content/docs/ui/actions.mdxwas found true and is unchanged.ctx.useron a write arunAs: 'system'approval flow makes after the decision now sees the decider. HotCRM's opportunity guards key onctx.user?.idbeing present, which stays true; the changeset says so.Generated by Claude Code