Skip to content

fix(plugin-approvals): a reminder's notified counts each approver address once, not once per slate slot - #24855

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24751-remind-notified-per-person
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24751-remind-notified-per-person

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

…ress once, not once per slate slot

A per_group slate lists a person once per group they resolved into, and
pending_approvers keeps every slot until the first vote. remind() looped
over the raw slots, so a person in two groups was counted twice while
messaging stored one reminder (both emits carry the same dedup key).
De-duplicate the concrete addresses before the loop, the opening
fan-out's openedOn rule. The literals branch, the per-approver dedup key,
the payload, the throttle and the audit row are unchanged.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…e reaches its notified assertion

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…e emitted audiences

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

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): node scripts/docs-audit/affected-docs.mjs --json 4be72813112e0f865f1890ba6540ae0a496a5664 → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T11:08Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main abd5acdaf7, not against the report (os-dev-report on #24751). Head under review:

f61fb763efac6ea0b995eebdea70f1d7e1b3feb4
  1. Shape. Draft, base main, first line Fixes #24751, line-start Clause-②: no in the body and the changeset. 3 files, +270/−1, none on a governed surface (governedPathsIn answers []). The trial merge onto origin/main is clean. PR feat(plugin-sharing,plugin-approvals,metadata-protocol)!: 18.x step 7 part 7a-3 — the organization-less readers of sharing rules, approval units and the seed loader retire #24828's region of approval-service.ts (~:2582–2851) is untouched.

  2. The change, read in the code.

    • In remind() only, concrete becomes new Set(pending.filter(...)). That is the opening fan-out's openedOn rule.
    • notified now counts one per distinct approver address, which equals the reminders handed to messaging.
    • The literals branch, notify(), the dedup key, the payload, the throttle, the submitter check and the audit row are unchanged.
    • No other line of approval-service.ts moves.
  3. The premise, measured on a composed stack. A per_group slate placing one user in two groups stores pending_approvers id,id. Before the fix, remind answered notified: 2 while messaging stored 1 reminder and 1 inbox row. An email listed twice behaved the same.

  4. Pins and ablation. remind-notified-per-person.integration.test.ts runs on a real automation engine and ApprovalService. It covers:

    • the same id twice, then the same email twice: one reminder handed over, notified 1, with the deep link, dedup key and audit actor read;
    • a two-person control;
    • a person-plus-literal control;
    • an id-and-email control.

    Ablation (the Set put back to the plain filter): 2 failed | 3 passed. The two twice-listed cases go red and the controls stay green. The restore was proven by blob equality.

  5. Changeset, @objectstack/plugin-approvals patch, read sentence by sentence. Each sentence matches 2 and 3:

    • what notified counts;
    • why a per_group slate repeats a person;
    • the opening's rule;
    • "a user id and the same account's email are still two addresses";
    • the "Unchanged" list.
  6. Gates. At ACCEPT, every check-run on this head is success or an expected skip, among them TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core and Governed Surface Queue Guard. Locally, 70 of 70 derived families exited 0. plugin-approvals passed 1169 of 1169.

  7. A choice the dev made under the decision frame: a user id and the same account's email stay two addresses, as at the opening. No producer in this tree writes both spellings for one person, so there is no reach. Accepted, and recorded in the PR's Acceptance notes.

  8. Accepted deviations.

    • Three ablation runs. The test was tightened twice so that notified is asserted first.
    • The card's "one reminder row stored" is pinned as emits handed to messaging, because the stored-row count is 1 both before and after.
    • A temporary dogfood measurement file was never committed.
    • Two merges of main.
    • Model-free trailers.
  9. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 11:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 11:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 9a06f0b Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24751-remind-notified-per-person branch October 20, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants