Repository navigation
Commit d12b0c1
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)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1vxQ5eGqwz99QtDkLa1w1V)_
Fixes #25990
Clause-②: no
Metadata-protocol: none
The sibling of #25758 (PR #25964), under the card's ruling: on a
`notify` node naming both `inbox` and `email`, 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: claim `6412253095` (branch
`claude/project-thread-puin6e`).
## What changed
| `notify` node with `channels` naming `email` and another channel |
Before | Now |
|---|---|---|
| written-out address no user holds, bare | mailed, AND a
`sys_inbox_message` row whose `user_id` is the address; success | mailed
only, no inbox row; success |
| `user:ADDRESS` (any address, held or not) | same junk row (the
resolver hands the remainder on as the id with no lookup) | mailed only;
success |
| address whose holder cannot be looked up (no data engine, lookup
throws) | junk row + mail; success | mailed only; success |
| written-out address a user holds, bare | that user's inbox + mail |
unchanged |
| computed address on a mailing node | resolved to its user, or the node
fails (ruling-B screen) | unchanged |
| node without `email` | fails on an unheld address (#25964) | unchanged
|
- `service-automation` `notify-node.ts`: new `mailOnlyAddresses`
collects the written-out recipients only the email channel can deliver,
using the exact test `refuseUndeliverableAddresses` (#25964) makes, now
shared as `holderOf` (the `sys_user` by `email` lookup, id projected,
system read) and `namesAddressAsUserId` (the `user:` rule).
`screenComputedAddresses` and `refuseUndeliverableAddresses` call the
same `holderOf`, so there is one address test in the file, not a second
resolver.
- When the node mails and names another channel, `execute` emits those
recipients apart with `channels: ['email']`, and every other recipient
as before on the node's `channels`. `notificationId` names the
every-channel notification (the one the inbox shows) when there is one;
`delivered`, `enqueued` and `failed` add up both emits. A node with no
such recipient makes exactly the one emit it made before.
- The `recipients` help served to the Studio inspector says what such an
address does.
- `scripts/engine-double-contract.pinned.json`: the new test's `findOne`
double, written by `check:engine-double-contract --write`.
- Changeset: `@objectstack/service-automation` `patch`.
## Where the skip lives (PM hypothesis, measured)
In the node, not the inbox channel. Measured on `main` `b0384f3f`:
- `EmitInput` (`service-messaging/src/messaging-service.ts:75`) carries
ONE `audience` and ONE `channels` list; 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.resolveEmail` keeps an unheld address verbatim on
purpose ("a downstream channel may still key a row by it"), and the
inbox channel keys `user_id` by whatever recipient it receives. Making
the inbox refuse a non-id would change what `user_id` means to that
channel for every producer (`collab.mention`, audit writers, any
`emit`), and on the outbox path it would still enqueue a
`sys_notification_delivery` row that then fails.
- The node is the one place that knows a recipient was WRITTEN OUT as an
address and which `channels` its 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 carried `Clause-②: yes (expected: … unchanged; the
dev measures)`; measured, the accept set is unchanged in both
directions, so the line is `no` with no arm.
## Tests
- New `notify-both-channels-unheld-address.integration.test.ts` (7
cases), real `MessagingService` (inline fan-out), real inbox and email
channels, data engine recording every row: bare unheld address,
`user:ADDRESS` unheld and held, a list beside a user (inbox row for the
user only, both mailed, `selected: 2, acted: 3`, two `sys_notification`
events), 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.
- **Red first** (commit `da686d76`, before the fix, on `main`
`b0384f3f`): 5 failed (each on the inbox row whose `user_id` is the
address), the 2 controls passed.
- **Ablation** (`scripts/ablation-replace.mjs`, one behaviour: the
`mailOnlyAddresses` call replaced by an empty set; the test imports the
node from `src`, no `dist` in the path): 5 failed, 17 passed across this
file and `notify-written-address-channels.integration.test.ts` (the 5
are exactly the fixed cases). Restore: blob `e7907826` equals HEAD, `git
diff HEAD` empty.
## Verification
Under the verify lock, at `1ce55062` (the ledger pin `745715539` after
it changes no source):
- `pnpm --filter @objectstack/service-automation test`: 205 files / 2689
passed; `typecheck` (with `check:test-typecheck`): exit 0.
- `eslint --no-inline-config packages/services/service-automation`: exit
0.
- Derived gates (`dispatch-gates --commands`, 76 families on the final
diff): 75 exit 0; `check:dual-build-cjs-loads` exit 3 PREREQUISITE NOT
MET (needs a full-repo build; CI runs it). Reconciled with `--ran`: 76
accounted, 0 unrun.
- Open PRs on `notify-node.ts`: none found (open-PR search on `notify` /
`service-automation`, 2026-10-26T10:02Z).
## Acceptance notes
- A run whose recipients mix users and such addresses now writes two
`sys_notification` events for one node (users on the node's `channels`,
addresses on `email`). The node's output keeps its keys;
`notificationId` names 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.
- The rule covers every channel beside `email`, not only `inbox`: on a
node naming `email` and `sms`, such an address is no longer handed to
sms, where it used to be a failed delivery (`no phone number for
recipient`), so `failed` drops by one for it.
- `@objectstack/spec` describe text for `recipients`
(`io-node-config.zod.ts`) does not say what a both-channels node does
with an unheld address; that is the `domain:spec` text, not in this PR.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01617Hq2GbkLYhXsfVgtvNuR)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a52cfaf commit d12b0c1
4 files changed
Lines changed: 305 additions & 17 deletions
File tree
- .changeset
- packages/services/service-automation/src/builtin
- scripts
| 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 | + | |
| 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 | + | |
0 commit comments