Skip to content

Commit 7282a95

Browse files
fix(plugin-approvals): the decision that ends an approval tells its submitter the outcome (#24816)
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. Now `ApprovalService.decide()` tells the submitter the outcome once the decision ends the approval process: - **Topics:** `approval.approved` ("Request approved") and `approval.rejected` ("Request rejected"), one per outcome. - **Shape:** the same as the sibling notices. It goes to the request's own `submitter_id`. The record is named by `nameSubjectRecord`, the label-and-id rule from #23556 (`Your Deal D_123 was rejected.`). The body is never empty, the rule from #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. - **Who is not told:** - a submitter who decided their own request; - anyone, on a co-sign vote that leaves the request pending; - anyone, when the resume opened a further approval step on the same run; - anyone, for the SLA sweep's `auto_approve` / `auto_reject`. The escalation's own submitter notice (`approval.sla_breached`, which names the action) remains the one message, so `notifySubmitter: false` still silences it. The sweep now calls a private `decideAndResume(..., 'escalation')`. The public `decide()` delegates to the same method with `'outcome'`. - **Docs:** a new callout in `content/docs/automation/approvals.mdx`. The old advice to "keep notify nodes that report the decision" is narrowed to recipients other than the submitter. - **Changeset:** `@objectstack/plugin-approvals` `minor`, `Clause-②: yes (widening)`. *(Seat edit: the declaration was re-made by the seat, claim amendment 6286428768, to match this lane's precedent #22607, which shipped the new `approval.requested` topic as `yes (widening)` and `minor`. The dev's patch round at `0110fecb03` moved the changeset; this line and line 3 are the seat's.)* ## Readings taken before writing it (origin/main a43405d) - **H1 holds.** The send-back paths send `approval.returned` (in `sendBack`, both the revision-limit auto-reject and the plain send-back), `requestInfo` sends `approval.request_info`, and `escalateRequest` notifies the submitter under `esc.notifySubmitter !== false`. All of them address `raw.submitter_id` and use the closed `{ title, body, actionUrl }` payload. No approval `notify()` call site reads the caller's locale; every body is one English sentence. - **H2, refined.** The status write for approve and reject is shared, in `decideNode`'s finalizing branch. The notice cannot be sent there, because only the resume in `decide()` shows whether the run moved on to another approval step. So the notice sits in `decide()`, after the resume. This also means a decision whose resume strands sends no notice, the same as a send-back in that state. - **No registry is needed.** `service-messaging` has no topic registry. `PreferenceResolver` matches 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 by `type` generically and holds no approval topic list. ## Tests Measured at head 2a13930. - New `approval-decision-outcome-notification.integration.test.ts`, 8 cases. It uses the real `ApprovalService` on a real `ObjectQL` over `SqlDriver` (better-sqlite3 in memory), with the real `AutomationEngine` running the flows and the real approval node opening each step. The cases: - approved; - rejected with a comment; - self-decided, with a control; - a unanimous co-sign; - a two-step ladder; - a rejection at step one; - a decision gateway that skips the second step; - an SLA auto-approve under `notifySubmitter` true 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, and `check:test-typecheck` is OK with the new file unledgered and error-free. - **Ablations.** Each leg used `scripts/ablation-replace.mjs`, which checks the anchor (1 to 0 hits), checks that the blob changed, and restores with `git checkout HEAD -- ABS_PATH`. Each restore was proven: blob equals HEAD, and `git diff HEAD` is empty. - Dropping the `notifyDecisionOutcome` call: 11 tests red across both files, including every negative case through its positive control. - Removing the self-decider guard: only the self-decided test is red. - Removing the next-step guard: only the two-step test is red. - Passing `'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. - **Derived list.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 98 commands from the 5-path diff. All 98 exited 0. - **Reconciliation.** `--ran` (with `:: exit N` records) answered: 98 derived, 98 run, 0 NOT-MEASURED, a derived zero. - **First pass, at 9827a64.** Three gates stopped with PREREQUISITE NOT MET (exit 3), so they measured nothing: `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n`. I built their prerequisites and re-ran them; all three exited 0. `check:query-options-erasure` was red (exit 1), because the new test file's two query-option bags were erased with an `any` cast (test surface 236 to 238). It is green at 236 since commit 2a13930, which types them (`satisfies EngineQueryOptions`). - **Not run locally.** `test:repo` is 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 - **Who a multi-step notice reaches.** A later approval step's request records the PREVIOUS step's decider as its `submitter_id`. Measured: on a manager-then-executive flow, step 1 holds `u_sub` and step 2 holds `u_mgr`. The cause is that `approval-node.ts` passes `context.userId` as `submitterId`, 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 stored `submitter_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. - **The revision-limit auto-reject keeps its old topic.** It still tells the submitter on `approval.returned` ("Approval auto-rejected"), not on `approval.rejected`. Unchanged here. - **Subflow boundary.** The "is there a next step" read covers the request's own run. If an approval inside a subflow is followed by another approval in the parent run, the notice is sent at the child's decision. This case was not measured. - **The action-link comment.** A decision through the action link records the comment "Via action link", and the outcome notice carries it as the comment paragraph. - **No resend after repair.** A decision whose resume strands sends no notice, and the operator repair path (`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](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c3a8e07 commit 7282a95

5 files changed

Lines changed: 518 additions & 9 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
---
4+
5+
feat(plugin-approvals): the decision that ends an approval tells its submitter the outcome (`approval.approved` / `approval.rejected`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What was wrong.** Approving or rejecting a request notified nobody. A send-back (`approval.returned`) and a question (`approval.request_info`) reached the submitter, but the decision itself did not, so a submitter learned that their request was approved or rejected only by opening the Approvals Inbox.
10+
- **What is new.** When a decision ends the approval, the submitter of the request it decides gets one notification on one of two new topics: `approval.approved` (title "Request approved") or `approval.rejected` (title "Request rejected"). The body names the record by its object's label and its id, as every approval notification does (`Your Deal D_123 was rejected.`), followed by the decider's comment as its own paragraph when they wrote one. It links to that request in the Approvals Inbox and, like the other approval notices, carries as its actor the person the decision's context vouches for. It goes through the installed `messaging` service like every other approval topic, so the same channels and delivery preferences apply; a preference row can mute one outcome and keep the other.
11+
- **When "ends the approval" holds.** A co-sign vote that leaves a `unanimous`, `quorum` or `per_group` request pending tells no one. A decision whose flow moves on to another approval step tells no one either; the decision that ends the run is the one that notifies. That is read from the run after the decision resumes it, not from the flow's graph, so when a decision node routes around a later step, the first decision is the outcome.
12+
- **Who is not told.** A submitter who decided their own request. And an SLA escalation's `auto_approve` or `auto_reject`: the escalation's own submitter notice (`approval.sla_breached`, naming the action taken) is the one message about it, so `escalation.notifySubmitter: false` still means the submitter hears nothing. A decision whose flow run cannot be resumed reports its failure to the decider and tells the submitter nothing, as a send-back in the same state already did.
13+
- **What to change in your flows.** If a flow ends with a `notify` node whose only job is to tell the submitter the decision, remove it, or the submitter is told twice. `notify` nodes that address anyone else are unaffected. No flow in this repository's examples has that workaround: the three showcase flows whose `notify` node reports a decision address someone other than the submitter — `showcase_invoice_signoff` and `showcase_project_closure` the record's owner, `showcase_release_signoff` a fixed address — so they are unchanged, and only a record owner who is also the person who submitted now hears both.

‎content/docs/automation/approvals.mdx‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -536,8 +536,35 @@ second one for the opening.
536536

537537
If a flow has a `notify` node beside its approval node only to tell the
538538
approvers that a request is waiting, remove that node: those approvers are now
539-
told twice. Keep `notify` nodes that address someone else, such as the
540-
submitter or a watcher, or that report the decision.
539+
told twice. Keep `notify` nodes that address someone else, such as a watcher,
540+
or that report the decision to anyone but the submitter.
541+
</Callout>
542+
543+
<Callout type="info">
544+
**The decision tells the submitter.** When a decision ends the approval, the
545+
submitter of the request it decides gets one notification: `approval.approved`
546+
("Request approved") or `approval.rejected` ("Request rejected"). Its body
547+
names the record (`Deal D_123`) and the outcome, followed by the decider's
548+
comment when they wrote one, and it links to that request in the Approvals
549+
Inbox. There is one topic per outcome, so a delivery preference can mute
550+
approvals and keep rejections.
551+
552+
"Ends the approval" means the process, not the step. A co-sign vote that
553+
leaves a `unanimous`, `quorum` or `per_group` request pending tells no one,
554+
and neither does a decision whose flow moves on to another approval step: on
555+
a two-step flow the first step's decision is silent and the decision that ends
556+
the run is the one that notifies. Whether a later step opened is read from the
557+
run, not from the flow's graph, so when a decision node routes around the
558+
later step, the first decision is the outcome and notifies.
559+
560+
Two cases tell nobody by design. A submitter who decided their own request
561+
already knows. An SLA `auto_approve` or `auto_reject` is told by the
562+
escalation's own submitter notice (`approval.sla_breached`, which names the
563+
action taken), so `notifySubmitter: false` still means the submitter hears
564+
nothing about it.
565+
566+
If a flow ends with a `notify` node whose only job is to tell the submitter
567+
the decision, remove that node: the submitter is now told twice.
541568
</Callout>
542569

543570
### Mount the approvals inbox in your app

0 commit comments

Comments
 (0)