Repository navigation
fix(service-automation): a notify node naming email and another channel mails an unheld written-out address and writes no inbox row for it - #26039
Conversation
… row for an unheld written-out address (red) Red on main b0384f3: 5 failed (the inbox row whose user_id is the address, for a bare address, user:ADDRESS, user:HELD, a list beside a user, and a failed directory lookup), 2 controls passed. Claude-Session: https://claude.ai/code/session_01617Hq2GbkLYhXsfVgtvNuR Co-authored-by: Claude <noreply@anthropic.com>
…el mails an unheld written-out address and writes no inbox row for it The node emits a written-out address that only the email channel can deliver (bare and held by no user, user:ADDRESS, or one whose holder could not be looked up) apart, on the email channel alone; every other recipient is emitted as before. The held test is the one refuseUndeliverableAddresses makes, now the shared holderOf / namesAddressAsUserId. Claude-Session: https://claude.ai/code/session_01617Hq2GbkLYhXsfVgtvNuR Co-authored-by: Claude <noreply@anthropic.com>
… in the engine-double ledger Written by check:engine-double-contract --write. Claude-Session: https://claude.ai/code/session_01617Hq2GbkLYhXsfVgtvNuR Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 8b8eb6085c83c2a62437b5c18a91bb1deaa16413 && git checkout 8b8eb6085c83c2a62437b5c18a91bb1deaa16413
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 431f065177de0cfc9d682b3b18692132390e0c57 745715539041813bf60dc8370647e742ea5a4709 && git checkout -B drift-repro 431f065177de0cfc9d682b3b18692132390e0c57 && git merge --no-ff 745715539041813bf60dc8370647e742ea5a4709
node scripts/docs-audit/affected-docs.mjs --json 431f065177de0cfc9d682b3b18692132390e0c57 |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | draft, base main, Fixes #25990, Clause-②: no (the claim expected yes, unchanged surface; the dev measured no, and the diff adds no accepted input and refuses none), Metadata-protocol: none, patch on @objectstack/service-automation. Four paths: notify-node.ts, one integration test, the changeset, and the engine-double-contract pin for the new test's findOne. |
| Fix | on a notify node whose channels name email and another channel, a written-out address only email can deliver (bare and unheld, user:ADDRESS, or a holder lookup that failed) is emitted apart on ['email']; every other recipient is emitted as before. The inbox writes no row keyed by an address. The holder test is one helper (holderOf / namesAddressAsUserId) shared with the #25758 and #25964 checks, so "a user holds it" means one thing in all three. |
| Unchanged | the node's success; a held bare address still reaches the holder's inbox and is mailed; a computed address still resolves or fails as before; a node not naming email still fails on an unheld address. |
| Run record | a mixed list writes two sys_notification events; notificationId names the every-channel one (the mail-only one when it is the only emit); delivered / enqueued / failed add up both. Declared in the changeset. |
| Evidence | red first (5 failed / 2 controls passed at the base), 7/7 on the head, ablation red then restored; package 205 files / 2689 passed; 75/76 derived gates exit 0, dual-build-cjs-loads answered by CI's Build Core. Auto-merge holds for the head's check-runs. |
| Noted | the @objectstack/spec describe text for notify.recipients does not yet say this (domain:spec text half); the node's own description does. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready + auto-merge through the relay. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #25990
Clause-②: no
Metadata-protocol: none
The sibling of #25758 (PR #25964), under the card's ruling: on a
notifynode naming bothinboxandemail, a written-out address no user holds goes to the email channel only, the inbox writes no row for it, and the node still succeeds. Identity: claim6412253095(branchclaude/project-thread-puin6e).What changed
notifynode withchannelsnamingemailand another channelsys_inbox_messagerow whoseuser_idis the address; successuser:ADDRESS(any address, held or not)emailservice-automationnotify-node.ts: newmailOnlyAddressescollects the written-out recipients only the email channel can deliver, using the exact testrefuseUndeliverableAddresses(fix(service-automation)!: a notify node that does not name email fails on a written-out address no user holds #25964) makes, now shared asholderOf(thesys_userbyemaillookup, id projected, system read) andnamesAddressAsUserId(theuser:rule).screenComputedAddressesandrefuseUndeliverableAddressescall the sameholderOf, so there is one address test in the file, not a second resolver.executeemits those recipients apart withchannels: ['email'], and every other recipient as before on the node'schannels.notificationIdnames the every-channel notification (the one the inbox shows) when there is one;delivered,enqueuedandfailedadd up both emits. A node with no such recipient makes exactly the one emit it made before.recipientshelp served to the Studio inspector says what such an address does.scripts/engine-double-contract.pinned.json: the new test'sfindOnedouble, written bycheck:engine-double-contract --write.@objectstack/service-automationpatch.Where the skip lives (PM hypothesis, measured)
In the node, not the inbox channel. Measured on
mainb0384f3f:EmitInput(service-messaging/src/messaging-service.ts:75) carries ONEaudienceand ONEchannelslist; there is no per-recipient channel restriction a producer can pass, so a node-side skip is two emits, and a messaging-side skip would be a new field on the service contract.RecipientResolver.resolveEmailkeeps an unheld address verbatim on purpose ("a downstream channel may still key a row by it"), and the inbox channel keysuser_idby whatever recipient it receives. Making the inbox refuse a non-id would change whatuser_idmeans to that channel for every producer (collab.mention, audit writers, anyemit), and on the outbox path it would still enqueue asys_notification_deliveryrow that then fails.channelsits author named, so the skip is there, and every channel and the resolver keep their one meaning of a recipient.Is it a narrowing (measured against AGENTS.md)
No. Every node that succeeded still succeeds and every node that failed still fails: the split runs only after the parse, the ruling-B screen and the #25758 refusal have answered, and it only chooses which channels an address is handed to. No key, value or stored shape moves and the parse is unchanged, so
Clause-②: no,Metadata-protocol: none,patch. The claim carriedClause-②: yes (expected: … unchanged; the dev measures); measured, the accept set is unchanged in both directions, so the line isnowith no arm.Tests
notify-both-channels-unheld-address.integration.test.ts(7 cases), realMessagingService(inline fan-out), real inbox and email channels, data engine recording every row: bare unheld address,user:ADDRESSunheld and held, a list beside a user (inbox row for the user only, both mailed,selected: 2, acted: 3, twosys_notificationevents), a directory lookup that throws; controls: a held bare address reaches its user's inbox and is mailed, a computed held address reaches its user's inbox.da686d76, before the fix, onmainb0384f3f): 5 failed (each on the inbox row whoseuser_idis the address), the 2 controls passed.scripts/ablation-replace.mjs, one behaviour: themailOnlyAddressescall replaced by an empty set; the test imports the node fromsrc, nodistin the path): 5 failed, 17 passed across this file andnotify-written-address-channels.integration.test.ts(the 5 are exactly the fixed cases). Restore: blobe7907826equals HEAD,git diff HEADempty.Verification
Under the verify lock, at
1ce55062(the ledger pin745715539after it changes no source):pnpm --filter @objectstack/service-automation test: 205 files / 2689 passed;typecheck(withcheck:test-typecheck): exit 0.eslint --no-inline-config packages/services/service-automation: exit 0.dispatch-gates --commands, 76 families on the final diff): 75 exit 0;check:dual-build-cjs-loadsexit 3 PREREQUISITE NOT MET (needs a full-repo build; CI runs it). Reconciled with--ran: 76 accounted, 0 unrun.notify-node.ts: none found (open-PR search onnotify/service-automation, 2026-10-26T10:02Z).Acceptance notes
sys_notificationevents for one node (users on the node'schannels, addresses onemail). The node's output keeps its keys;notificationIdnames the first. If the second emit throws after the first was accepted, the node fails with the users' notification already written: a partial outcome one emit could not have.emit()throws only before its fan-out (a malformed keyed text, an event write the engine refuses), and both emits carry the same input, so the second refusing after the first was accepted needs the engine to change its answer between them.email, not onlyinbox: on a node namingemailandsms, such an address is no longer handed to sms, where it used to be a failed delivery (no phone number for recipient), sofaileddrops by one for it.@objectstack/specdescribe text forrecipients(io-node-config.zod.ts) does not say what a both-channels node does with an unheld address; that is thedomain:spectext, not in this PR.Generated by Claude Code