Repository navigation
feat(plugin-approvals): opening an approval step tells each resolved approver (approval.requested) - #22625
Conversation
…approver (approval.requested) openNodeRequest publishes approval.requested to each concrete approver on the slate the request opened on, through the same notify() ingress and payload shape as approval.reminder: once per person, never to a type:value literal, not to an out-of-office delegate already told by approval.ooo_substituted. A slate of literals notifies nobody, and onEmptyApprovers decides that case. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…ded person Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…p sends after the opening Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…es manager_id The guide's callout that opening a request notifies nobody is rewritten for the approval.requested topic, with the remedy for flows that added a notify node beside the approval node. The manager_id sentences name the Console surface that writes the column: Setup, Users, Set Manager. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…proval-opened-topic
📓 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 389cc8be8a45453940a3ed63cb3e29bae05dc387 && git checkout 389cc8be8a45453940a3ed63cb3e29bae05dc387
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e22315238f9426708e10cfff1d6a96e56cca33a1 05acc5d51fe577358bbdbb714f08983e2c7d1647 && git checkout -B drift-repro e22315238f9426708e10cfff1d6a96e56cca33a1 && git merge --no-ff 05acc5d51fe577358bbdbb714f08983e2c7d1647
node scripts/docs-audit/affected-docs.mjs --json e22315238f9426708e10cfff1d6a96e56cca33a1
|
Contract reviewServed-tier: Rendered 2026-10-10T06:33Z on PR #22625 (card #22607). The head is the merge of Check-runs on the head (latest run per name; read six times, one curl a minute, until none was in progress; the last run completed 2026-10-10T06:31Z): 35 runs, 33 ① Derived judgmentsGovernance frame first: the file list touches no governed surface (no
② Semver level
Clause-②: yes (widening) — a new public notification topic on a released package; the changeset declares it; nothing in the diff narrows. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22607
Clause-②: yes
Opening an approval step now tells the people it landed on.
openNodeRequestpublishes the new topicapproval.requestedto each concrete approver on the slate the request opened on. It goes throughnotify(), the same single ingress (ADR-0030) and payload shape thatapproval.reminderand every other sibling topic use. There is no second notification path. The approvals guide's callout "Opening a request notifies nobody" is rewritten. Itsmanager_idsentences now name the Console surface that writes the column: Setup → Users → ⋯ → Set Manager.Dispatched by the
domain:servicesseat 1 PM, sessionsession_013j5gkUCpqQiti4GgPqqmnt. Claim6094034487, triage6093976698.Measured first: who followed the guide's workaround
The guide used to recommend "a
notifynode next to the approval node" as the way to tell approvers. A flow that did that would now tell the same approvers twice. I counted every flow that holds an approval node, and classified eachnotifynode in it by edge reachability: reachable from an approval node means it runs after the decision (outcome messaging, not the workaround), otherwise it runs before or beside the approval node (a workaround candidate).notifynodes in those flowsexamples/(d748ae80af; 31 flows scanned)showcase_invoice_signoff.notify_cleared, after the decision)objectstack-ai/hotcrmsrc/(f0afcbd)notify_approved/notify_rejected)objectstack-ai/hotclmsrc/(46e65f0, and PR #101's head0089ffa)notify_approved/notify_rejected)Control on the parser: hotcrm's grep for approval nodes returns 11 hits = 5 doc-comment mentions + the 6 real nodes the parser found. I also searched all three trees for a flow or hook triggered by
sys_approval_requestcreation that notifies approvers. None exists. No measured app double-notifies. The changeset still names the remedy for apps outside these trees.Where the topic is declared
Measured: nowhere but the call site. The sibling topics are string literals at their
notify()call sites inapproval-service.ts.service-messagingkeeps no topic catalog: a topic is a free string, keyed only by the preference filter, andmandatoryTopicsdefaults to empty.packages/specdeclares none of them. So this PR adds no spec change and no registry row.The name is
approval.requested. The card asks for "anapproval.requested-style topic", ADR-0012's Layer-1 event example namesapproval.requested, and the sibling vocabulary uses past participles (reassigned,escalated,returned,ooo_substituted).The recipient rule, and how it matches the reminder's
remind()fans out over the slate's concrete identities (a user id or an email), sending onenotify()per approver. Atype:valueliteral gets no personal message:notify()drops every slot address from every audience, so the reminder's literal arm reaches nobody. The opening applies the same rule to the slate it actually opened on, which is after OOO delegation and after anonEmptyApprovers: 'fallback'replacement:audience: [approver],source= the request,actionUrldeep-linked to it bynotify(), and the opening run's person as the actor;position:NAMEwith no holders) gets no message. So a slate of literals only notifies nobody, andonEmptyApproversdecides that case:admin_rescueopens and tells nobody,auto_approveandfailopen nothing,fallbackopens on and tells the fallback approvers;approval.ooo_substituted, not a second one for the opening.The send happens last, after every write of the opening has landed. Like every
notify(), it is best-effort: a channel that throws logs a warning and never fails the opening.Deliberately not included: one-tap Approve / Reject links. ADR-0043 mints those bearer tokens in
remind()only, and minting them on every opening is a decision this card does not make.Pins (
approval-requested-notification.integration.test.ts)These run on the real
ApprovalService, on a realObjectQLoverSqlDriver(in-memory better-sqlite3, real DDL). No new fake engine.onEmptyApproversdecides it. Four cases:admin_rescue(opens, tells nobody),auto_approve(no request, no emit),fail(refused, no emit),fallback(opens on and tells the fallback approver).approval.ooo_substituted.Existing batteries widened to the new call site:
approval-notification-body.integration.test.ts(the site census is now twelve of thirteen call sites, and every new emit carries a non-emptybodyand no undeclared payload field) andactor-lookups-hold-ids.integration.test.ts(the opening forwards the person, never the email slot). Four tests inapproval-service.test.tsattached messaging before opening and then reademitted[0]or the total count as if their own step were the only sender. Each now asserts that the opening sent exactlyapproval.requested, clears the array, and keeps its original assertions unchanged.Ablation: the publish removed
node scripts/ablation-replace.mjsreplacedfor (const approver of openedOn) {withfor (const approver of [] as string[]) {(anchor hits 1 → 0, blob2a30646be68a→8e67159c79bf). It ran at committed HEAD36f03883d3, which holds the implementation and every pin, with a trap-armed restore. The subject is imported relatively fromsrc/, so nodist/is involved.admin_rescue,auto_approve,fail).2a30646be68a= HEAD's blob,git diff HEADempty,git statusclean.Docs (
content/docs/automation/approvals.mdx):451) is rewritten to describe the new topic: who is told, who is not and why, and the remedy for a flow with anotifynode beside its approval node.:380: "sys_user.manager_id, which no product surface writes" now names Setup → Users → ⋯ → Set Manager. Verified onmain:sys_user's row actionset_user_manager("Set Manager", list row menu and record header, platform admins only, hidden foridp_provisionedidentities) posts toPOST /api/v1/auth/admin/set-user-manager.:32carried the identical false sentence ("resolves from a column no product surface writes") and now names the same surface. Themanagercallout's "A platform admin posts the link" now leads with the same Console action before the endpoint. All four in-place conditions hold: same defect class as item 2, a mechanical rewrite in the form item 2 fixes, no other claim on the file (docs(approvals):approvals.mdxsays an approver entry that resolves to nobody opens an empty slate that "parks forever"; group entries keep theirtype:valueliteral, and every empty slate reachesonEmptyApprovers#22584, which covers:384–:386of this page, is an unclaimedpm:queueissue), and the same gate family. docs(approvals):approvals.mdxsays an approver entry that resolves to nobody opens an empty slate that "parks forever"; group entries keep theirtype:valueliteral, and every empty slate reachesonEmptyApprovers#22584's sentence itself is untouched.Verification
Final runs were taken on HEAD
05acc5d51f, after mergingorigin/main18d999031b:pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2:: exit 0, 67 files / 972 tests passed (the new file has 9 tests).pnpm --filter @objectstack/plugin-approvals typecheck:: exit 0 (tsc --noEmittwice, thencheck:test-typecheck: OK, the test layer compiles with 27 pinned signatures held and no new debt).pnpm --filter @objectstack/spec exec vitest run --project repo(spectest:repo, which scanscontent/docs) :: exit 0, 54 files / 915 tests passed.pnpm --filter @objectstack/spec check:generated:: exit 0, "All 15 generated artifacts are up to date".node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 96 gate commands from this diff. All 96 ran on this HEAD, each exit 0.--ranreconciliation: "96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero because every line carries its exit code. On the pre-merge HEAD,check:skill-examples,check:i18nandcheck:dual-build-cjs-loadsfirst answered exit 3 (PREREQUISITE NOT MET, missingdist/). After their named prerequisite builds they measured green, and the post-merge sweep ran them green.integration-tier and dogfood suites of other packages, and the repo-wide lint. Those runs belong to CI. Out-of-package readers of approver inboxes were checked by reading them (Acceptance notes).Acceptance notes
docs/adr/0043-actionable-approval-links.md:40–:42(Accepted) says "(Open-time notification remains the flow author'snotifynode; templates there can adopt the same links later.)" After this PR that sentence is stale. I read it as a v1 scope note, not a recorded decision: it sits in Mechanics, weighs no alternative, gives no rationale, and anticipates a "later". ADR-0030's single-ingress decision also names "approval requests" as a producer. Amending ADR-0043 is a Tier H edit and is not in this PR. It is raised as an open question in the dev report.docs/qa/platform-checklist/areas/approvals.json:341,:394and:400state that "opening a request notifies NOBODY" and that "no approval.opened topic exists". After this PR those notes are stale but harmless: the fixtures they describe still driveremind/reassign. The file is outside this claim's surface; owner: none.packages/qa/dogfood(outside this claim's surface). The new emit uses the reminder's channel, whose delivery tosys_inbox_messagethe card measured on 17.7.0 (remind→ the Executive's inboxtotal 1).examples/app-showcase/src/automation/flows/index.ts:1262says the dual sign-off's approvers "are notified concurrently". That is now true for staffed holders, so no change.approval-notification-body.dogfood.test.ts,seed-ownership-claim-dispatch.dogfood.test.ts,examples/app-showcase/test/approval-resume-relation-expand.test.ts). None counts all of an approver's messages.Generated by Claude Code