Repository navigation
fix(plugin-approvals): the decision that ends an approval tells its submitter the outcome - #24816
Conversation
…submitter the outcome approval.approved / approval.rejected reach the request's submitter once the decision ends the approval process: not on a co-sign vote that leaves the request pending, not when the resume opened a further approval step on the same run, not when the submitter decided it, and not for the SLA sweep's auto-decision, which the escalation's notifySubmitter notice tells. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…es through Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…ld; changeset Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…of erasing them Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
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 1c0d4620b5a9521e0b61464aad9a6aa38e4715da && git checkout 1c0d4620b5a9521e0b61464aad9a6aa38e4715da
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c56115bf962cb301fb046c54680620d37e5ab2fb c16244e6a46c4a12be703a07d1a5b8343639ce9c && git checkout -B drift-repro c56115bf962cb301fb046c54680620d37e5ab2fb && git merge --no-ff c16244e6a46c4a12be703a07d1a5b8343639ce9c
node scripts/docs-audit/affected-docs.mjs --json c56115bf962cb301fb046c54680620d37e5ab2fb
|
…use-② yes (widening) The two new subscribable topics follow the in-lane precedent for a new approval topic. The changeset also states what git grep measured: no example flow tells the submitter the decision with a notify node. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…itter as the design A later approval step recording the previous decider as its submitter is a defect filed on its own card, so neither the changeset nor the page states it as how the notice behaves. The changeset summary reads feat, matching its minor bump. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsThe face is the
② Semver level
③ Boundary flagsAll six dev
Each deviation is accepted:
Nothing is escalated. Implemented-by: VERDICT: PASS Rendered in-seat at Generated by Claude Code |
席位核对 · ACCEPT ·
|
Fixes #24753
Clause-②: yes
What this changes
Approving or rejecting a request notified nobody. A send-back (
approval.returned) and a question (approval.request_info) reached the submitter; the decision did not. NowApprovalService.decide()tells the submitter the outcome once the decision ends the approval process:approval.approved("Request approved") andapproval.rejected("Request rejected"), one per outcome.submitter_id. The record is named bynameSubjectRecord, the label-and-id rule from plugin-approvals: an approval request's notification names the record by its raw id (crm_opportunity/<id>), and opening one on an empty slot logs "cannot advance" just before routing to the administrators #23556 (Your Deal D_123 was rejected.). The body is never empty, the rule from approvals: every approval notification reaches the recipient with an empty body — the service puts its text in payload.message, messaging reads payload.body (comment and request-info text are lost) #21847. When the decider wrote a comment, it follows the sentence as its own paragraph.notify()deep-links the notice to/system/approvals?request=ID. The actor is the person the decision's context vouches for. The organization is the request's.auto_approve/auto_reject. The escalation's own submitter notice (approval.sla_breached, which names the action) remains the one message, sonotifySubmitter: falsestill silences it. The sweep now calls a privatedecideAndResume(..., 'escalation'). The publicdecide()delegates to the same method with'outcome'.content/docs/automation/approvals.mdx. The old advice to "keep notify nodes that report the decision" is narrowed to recipients other than the submitter.@objectstack/plugin-approvalsminor,Clause-②: yes (widening). (Seat edit: the declaration was re-made by the seat, claim amendment 6286428768, to match this lane's precedent plugin-approvals (17.7.0): opening an approval step notifies none of its approvers — no inbox message, no email; the docs also still say no product surface writesmanager_id#22607, which shipped the newapproval.requestedtopic asyes (widening)andminor. The dev's patch round at0110fecb03moved the changeset; this line and line 3 are the seat's.)Readings taken before writing it (origin/main a43405d)
approval.returned(insendBack, both the revision-limit auto-reject and the plain send-back),requestInfosendsapproval.request_info, andescalateRequestnotifies the submitter underesc.notifySubmitter !== false. All of them addressraw.submitter_idand use the closed{ title, body, actionUrl }payload. No approvalnotify()call site reads the caller's locale; every body is one English sentence.decideNode's finalizing branch. The notice cannot be sent there, because only the resume indecide()shows whether the run moved on to another approval step. So the notice sits indecide(), after the resume. This also means a decision whose resume strands sends no notice, the same as a send-back in that state.service-messaginghas no topic registry.PreferenceResolvermatches the exact topic or*(default on, fail-open), so the new topics are picked up with no wiring, and a user can mute one outcome and keep the other. objectui (local checkout 90518cd) groups inbox arrivals bytypegenerically and holds no approval topic list.Tests
Measured at head 2a13930.
approval-decision-outcome-notification.integration.test.ts, 8 cases. It uses the realApprovalServiceon a realObjectQLoverSqlDriver(better-sqlite3 in memory), with the realAutomationEnginerunning the flows and the real approval node opening each step. The cases:notifySubmittertrue and false, with a human-decision control.approval-notification-body.integration.test.ts: the battery drives the new call site, so its "every call site" claim still holds (now thirteen of fourteen). The body, record-name and organization pins cover both new topics.pnpm --filter @objectstack/plugin-approvals test: 87 files, 1155 tests passed.typecheck: exit 0, andcheck:test-typecheckis OK with the new file unledgered and error-free.scripts/ablation-replace.mjs, which checks the anchor (1 to 0 hits), checks that the blob changed, and restores withgit checkout HEAD -- ABS_PATH. Each restore was proven: blob equals HEAD, andgit diff HEADis empty.notifyDecisionOutcomecall: 11 tests red across both files, including every negative case through its positive control.'outcome'from the SLA sweep: only the SLA test is red.Gates (local subset)
All of these were run at head 2a13930. Each exit code was captured before any pipe.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 98 commands from the 5-path diff. All 98 exited 0.--ran(with:: exit Nrecords) answered: 98 derived, 98 run, 0 NOT-MEASURED, a derived zero.check:skill-examples,check:dual-build-cjs-loadsandcheck:i18n. I built their prerequisites and re-ran them; all three exited 0.check:query-options-erasurewas red (exit 1), because the new test file's two query-option bags were erased with ananycast (test surface 236 to 238). It is green at 236 since commit 2a13930, which types them (satisfies EngineQueryOptions).test:repois not run here; this diff touches none of its tests, so it is left to CI. The full-battery dogfood pin (approval-notification-body.dogfood.test.ts) is also left to CI. The CI families are left to the PR's own check-runs.Acceptance notes
submitter_id. Measured: on a manager-then-executive flow, step 1 holdsu_suband step 2 holdsu_mgr. The cause is thatapproval-node.tspassescontext.userIdassubmitterId, and the resume-as-decider hand-off has switched that to the decider. So on a multi-step flow the final notice reaches that person, as step 2's send-back, request-info and SLA submitter notices already do. The pin asserts the notice follows the terminal request's storedsubmitter_id, so it stays green whichever person that field ends up naming. Reported to the seat as a finding and left unchanged here: it is outside this card's file surface and is a different defect.approval.returned("Approval auto-rejected"), not onapproval.rejected. Unchanged here.continueRestoredRun) does not send one later.Out of scope
#22607 is the precedent for adding an approval topic. It is referenced, not touched. #23556 and #21847 are the families whose rules this notice follows.
Generated by Claude Code