Repository navigation
Commit 9a06f0b
fix(plugin-approvals): a reminder's notified counts each approver address once, not once per slate slot (#24855)
Fixes #24751
Clause-②: no
## What changed
`ApprovalService.remind()` (`POST
/api/v1/approvals/requests/:id/remind`) now de-duplicates the slate's
concrete addresses before its per-approver loop, the rule the opening
fan-out already applies (`openedOn`, a `Set`). `notified` is the number
of reminders handed to messaging, one per distinct approver address. The
code change is one line, `const concrete = new
Set(pending.filter(...))`, plus the comment that states the rule.
Unchanged: who is reminded; the reminder's topic, payload, deep link and
per-approver dedup key; the throttle and the submitter check; the
`remind` audit row; the shared nudge for `position:*` literals.
`notify()` and the opening fan-out are untouched. No `packages/spec`
edit: `IApprovalService.remind` says "notify every pending approver" and
does not define `notified`.
## The premise, measured before the fix
Measured on a composed stack: `bootStack` with the approvals source at
`5fcc794972`, the real `MessagingServicePlugin` (inline fan-out) and the
REST door. The fixture is a `per_group` flow whose two groups each read
one approver from a record field. The measurement file was temporary and
is not in this PR.
| slate, two groups | stored `pending_approvers` | opening
`approval.requested` events | remind `notified` | reminder events /
inbox rows |
|---|---|---|---|---|
| same user id twice | `id,id` | 1 | **2** | 1 / 1 |
| same email twice | `email,email` | 1 | **2** | 1 / 1 |
| a user id + that account's email | `id,email` | 2, both delivered to
the one user | 2 | 2 / 2, both to the one user |
| two people | `a,b` | 2 | 2 | 2 / 2 |
The duplicate is real, and it is reachable from the public door.
`openNodeRequest` stores `approvers.join(',')`, and `expandApprovers`
pushes one entry per group membership. The slate stays like that from
the opening until the first vote, because a mid-tally vote rebuilds
`pending_approvers` from `Object.keys(__approverGroups)`, which is
already de-duplicated. Real producers, by reading and not re-measured
here: the showcase seed grants the dev admin `manager` / `finance` /
`legal` / `exec`. So `showcase_invoice_signoff` (`unanimous`: finance +
legal) opens on that admin twice, and `showcase_committee_quorum`
(manager / finance / legal) opens on them three times.
## A user id and the same account's email stay two addresses
Measured above: messaging's `RecipientResolver` resolves an email-shaped
address to its `sys_user` id. The two emits carry different dedup keys,
so both land, and the one person gets two inbox rows, at the opening and
at the reminder alike. This PR keeps that as two addresses and two
reminders, with `notified: 2`. Read along the decision frame's four
axes:
- **Real business need.** The repeated-id shape has real producers
(above). An id plus the same person's email requires an author to write
both spellings for one person, and no example app or seed in this tree
does. Folding them would serve a speculative shape.
- **Long-term soundness.** A fold needs an email-to-id directory read
inside `remind()`, and that would be a second address equivalence.
`approver-address.ts` forbids writing one beside its own. A fold at the
reminder alone would also make the reminder disagree with the opening
fan-out, which the brief puts out of scope here. Exact-address
de-duplication is the rule the opening already applies, so one rule now
holds at both doors.
- **Preventing AI authoring mistakes.** Neither option tightens or
loosens a contract. Keeping one rule at both doors leaves nothing to
learn twice.
- **No scope creep at the startup stage.** The exact-address `Set` is
the smallest change that makes the count agree with what messaging
delivers. In every measured shape, `notified` now equals the reminders
delivered.
The slate holds two slots for that person (`heldSlot` takes the id
first, and the email slot needs its own decision), so two reminders is
also what the slate asks of them.
## Pin
`packages/plugins/plugin-approvals/src/remind-notified-per-person.integration.test.ts`
drives the real `ApprovalService` and the real `AutomationEngine`
running each flow, with the real approval node opening each request. The
stack is a real `ObjectQL` over `SqlDriver` (better-sqlite3, in-memory,
real DDL), and messaging records every emit it is handed. The shape is
the one `approval-decision-outcome-notification.integration.test.ts`
uses.
- one approver in two groups: the stored slate lists them twice (the
scene), `notified` is 1, and one reminder is handed over. Also checked:
the deep link, the per-approver dedup key, and one `remind` audit row by
the submitter;
- the same email in two groups: `notified` 1, one reminder;
- control, two distinct approvers: `notified` 2, two reminders;
- `position:nobody_holds` beside a person: the literal adds nothing and
is carried by no emit, and the person is still reminded;
- an id plus that account's email: two addresses, `notified` 2, two
reminders;
- every case also asserts, softly, that the reminder reached exactly the
addresses the opening's `approval.requested` reached.
On "one reminder row is stored": real messaging stores ONE reminder for
the twice-listed person both before and after this fix (the second emit
is a dedup hit, as the table shows), so a stored-row count cannot tell
the fix from the defect. The pin asserts the boundary the fix actually
moves instead: the emits handed to messaging, and `notified`.
## Ablation (one-time proof, not kept)
The mutation was run through `scripts/ablation-replace.mjs` in wrap
mode, under the shared lock, with an absolute-path EXIT/INT/TERM trap.
It replaced `const concrete = new Set(pending.filter(a => a &&
!a.includes(':')));` with the plain `pending.filter(...)`, on committed
head `5a3150311d`. The fix's subject resolves by relative import to
`src/`, so no `dist/` rebuild was involved.
- landed: anchor 1 → 0, blob `ea15dca72333` → `30e19d945490`;
- red as expected: `Tests 2 failed | 3 passed (5)`. The twice-by-id and
twice-by-email cases each failed `expected 2 to be 1` on `notified`,
plus the soft opening cross-check (two emits against the opening's one).
The two-people, literal and id-plus-email controls stayed green;
- restored: blob after restore `ea15dca72333` == the blob at `HEAD`, and
`git diff HEAD` is empty.
## Verification
Run on head `f61fb763ef`, with `origin/main` `41646a5788` merged in,
after the repository build (`turbo run build
--filter=!@objectstack/docs`, 76/76 tasks):
- `pnpm --filter @objectstack/plugin-approvals exec vitest run
--maxWorkers=2`: `Test Files 89 passed (89)`, `Tests 1169 passed
(1169)`;
- `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0.
`check:test-typecheck: OK` (8 files / 324 errors / 27 signatures held in
the ledger, unchanged), and the new test file is in the
`tsconfig.test.json` program (`--listFilesOnly`);
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 70 commands from this diff. All 70
ran on this head, each exit code captured before any pipe: 70 × exit 0.
`--ran`: `70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN`. On an earlier
head, two of them (`check:dual-build-cjs-loads`, `check:i18n`) first
refused with exit 3 (PREREQUISITE NOT MET: packages outside this closure
had no `dist/`). They went green after the full build, and green again
on this head. Every family CI runs is read from this PR's check-runs.
## Acceptance notes
- **A user id and the same account's email on one slate get two
reminders and two "Approval requested" notices**, both at the opening
and at the reminder. This is a recorded boundary, not a defect claim:
the slate really holds two slots for that person, and no producer in
this tree writes both spellings for one person. Not filed. Carrier:
none.
- **The stored-row half of the card's reproduction does not
discriminate.** Real messaging already stored one reminder; the defect
was only in the count. See Pin above.
Related, and already merged on its side: objectstack-ai/objectui#12781.
That change stopped the console's own double count of the same reminder.
This PR corrects the framework count the console displays.
---
_Generated by [Claude
Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 950f949 commit 9a06f0b
3 files changed
Lines changed: 270 additions & 1 deletion
File tree
- .changeset
- packages/plugins/plugin-approvals/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5477 | 5477 | | |
5478 | 5478 | | |
5479 | 5479 | | |
| 5480 | + | |
| 5481 | + | |
| 5482 | + | |
| 5483 | + | |
| 5484 | + | |
| 5485 | + | |
| 5486 | + | |
| 5487 | + | |
| 5488 | + | |
| 5489 | + | |
5480 | 5490 | | |
5481 | 5491 | | |
5482 | | - | |
| 5492 | + | |
5483 | 5493 | | |
5484 | 5494 | | |
5485 | 5495 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
0 commit comments