You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit b0384f3
Browse filesBrowse the repository at this point in the historyBrowse files
fix(service-automation)!: a notify node that does not name email fails on a written-out address no user holds (#25964)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X8XLfKt3As1Z43DaPWMDwjx)_Fixes#25758
Clause-②: no (narrowing: a notify node whose channels do not include
email, and whose recipients hold an email address no user holds, written
out or evaluated by a value expression, bare or as user:ADDRESS, now
fails the node at run time instead of reporting success; the seat runs
the contract review before the queue)
Metadata-protocol: none
The server half of the card (triage `6396218293`), under the seat's
ruling: fail loud, never re-route. The authoring refusal at `os
validate` / publish is a `domain:spec` card and is not in this PR.
## What changed
Only the email channel sends to an address itself. The inbox keys its
row by the recipient as a user id, and the sms channel texts a user's
number or a literal phone number. So on a node that does not name
`email`, a written-out address reaches someone only when the messaging
resolver maps it to the user who holds it, and the resolver maps only a
bare address (it hands the remainder of `user:a@b.c` on as the id with
no lookup).
| `notify` node | Written-out recipient | Before | Now |
|---|---|---|---|
| `channels` absent (inbox), `inbox`, or `sms` | address no user holds |
`sys_inbox_message` row with the address in `user_id`, which no inbox
reads; node **success** | node **fails**, names node, recipient and fix;
nothing written |
| same | `user:ADDRESS` (any address) | same junk row, node success |
node **fails** |
| same | address a user holds, bare | that user's inbox | unchanged |
| same | no data engine, or the lookup fails | inbox not persisted /
junk row, node success | node **fails** (the address could not be
checked) |
| `channels` include `email` | any address | sent as written (ruling B)
| unchanged |
| any | recipient a value expression computes | per ruling B scope A:
junk row, node success | judged like a written-out one, named by its
source, never its value (option B, ruled by the maintainer
2026-10-26T08:02Z: commits `d352c68a` + `55f6fc27`) |
- `service-automation` `notify-node.ts`: `resolveRecipients` now also
returns `written`, each recipient written out as a string that reads as
an address (the same `mailedAddressOf` reading the ruling-B screen
uses). New `refuseUndeliverableAddresses` runs only when `channels` do
not include `email`: a `user:ADDRESS` entry is refused outright; a bare
address is looked up as the resolver looks it up (`sys_user` by `email`,
the id projected, system read), and refused when no user holds it. All
such recipients of a list are named in one message. The node never adds
the `email` channel and never writes the row. The `recipients` help
served to the Studio inspector says what a written-out address does.
- Sample message: ``notify: node `alert_exec` names no `channels`, so it
delivers to the inbox, the default, and writes out a recipient no user
receives: `exec@example.com` (no user holds that address). Only the
email channel sends to an address; the others deliver to a user, so
nobody would receive it, and nothing was sent. To mail the address as
written, add `email` to the node's `channels`. To reach a user, name the
user: a user id, an audience selector such as `role:NAME`, or the
address that user holds, written bare.``
- `examples/app-showcase` node `alert_exec` (flow
`showcase_project_escalation`): `recipients: 'exec@example.com'` on the
inbox becomes `recipients: 'role:owner'`. Measured: the showcase's mail
goes through `plugin-email`'s default `log` transport, so adding `email`
would only log a mail to `example.com`; `role:owner` reaches the
organization's owners, and `ensure-default-organization.ts` makes the
admin the Default Organization's `owner`, so the "Alert Exec" branch now
reaches a real inbox.
- `examples/app-showcase` node `notify_cleared` (flow
`showcase_invoice_signoff`, option B, contract review `6411272864` F1):
`recipients: 'record.owner'` on the inbox becomes `recipients:
'role:owner'`. The seeded invoice owners (ada@, linus@,
grace@example.com) are the ADR-0055 isolation fixture and must stay
unprovisioned (`showcase-invoice-seed-isolation.dogfood.test.ts` signs
up as ada@), so under B a computed owner address no user holds would
fail the node; the Default Organization's admin, who holds the finance
and legal positions that clear the request, receives it instead.
- Showcase nodes that keep a written-out `admin@objectos.ai` on the
inbox (`digest`, `log_fail`, `notify_done`, `report`): they hold on a
dev boot, where the admin is provisioned, and fail loud on a boot
without that user (contract review F6); named in the changeset.
- `scripts/engine-double-contract.pinned.json`: the new test's `findOne`
double, written by `check:engine-double-contract --write`.
- Changeset: `@objectstack/service-automation` `patch`, BREAKING banner,
ADR-0087 `not-required (no-migration-prescription)`.
## Where the check lives (PM hypothesis, measured)
Channels registered on `main` (`messaging-service-plugin.ts`): `inbox`,
`email`, `sms`, no others in the repo. Which take a user id: `inbox`
only a user id, and silently keys any value (`inbox-channel.ts:149`,
`:205`); `sms` a user id or a literal phone number, and an address there
already fails the delivery (`no phone number for recipient`); `email` a
user id or an address. The node is the one place that knows both the
recipient's provenance (written or computed) and the node's `channels`,
so the check is there and covers inbox and sms alike. No channel
changes.
## Is it a narrowing (measured against AGENTS.md)
Yes, at run time: a run that used to complete now fails at this node,
and a failing node in a `parallel` branch or before later nodes changes
what the run does. The parse and `os validate` are unchanged and no key,
value or stored shape moves, so `Metadata-protocol: none`, `patch`, the
precedent of PR #25954 and of the run-time half in `.changeset/25572-*`.
The claim's `Clause-②: no` carried no arm; this PR adds `(narrowing:
…)`, so the contract review is owed.
## Tests
- New `notify-written-address-channels.integration.test.ts` (10 cases),
real `MessagingService` (inline fan-out), real inbox and email channels,
data engine recording every row: refusal for inbox default, `inbox`,
`sms`, `user:ADDRESS` (unheld and held), a list naming two; controls: an
address a user holds reaches that user's inbox, `email` and
`inbox`+`email` nodes mail the address as written; no data engine
refuses.
- `examples/app-showcase/test/approval-resume-relation-expand.test.ts`:
boots the dev-boot directory (admin as owner member, a provisioned
persona) and approves the app's own INV-1010 seed row through
`notify_cleared` to `end_ok`, the notice landing in the admin's inbox;
red on the old flow at `d0e593bb` (`RESUME_FAILED` at `notify_cleared`).
- **Red first** (commit `fc0c961b`, before the fix, on `main`
`17c5e68b`): 7 failed (every refusal case and the no-engine case, each
`expected true to be false` on `run.success`), 3 controls passed.
- Ablation (`scripts/ablation-replace.mjs`, one behaviour: the refusal's
early return, `written.length === 0` to `>= 0`; the test imports the
node from `src`, no `dist` in the path): 7 failed, 26 passed (the 7 are
exactly the refusal cases; the email-field suite stays green). Restore:
blob `33f2d3a7` equals HEAD, `git diff HEAD` empty.
## Verification
At head `47c6ea05` unless noted, under the verify lock:
- `pnpm --filter @objectstack/service-automation test`: 204 files / 2677
passed; `typecheck` (with `check:test-typecheck`): exit 0.
- `pnpm --filter @objectstack/example-showcase test`: 39 files / 484
passed; `typecheck`: exit 0 (after building its dependency closure).
- `eslint --no-inline-config` on the three changed TypeScript files:
exit 0.
- Derived gates and the dogfood battery: in the `os-dev-report` on
#25758.
## Acceptance notes
- Out of this PR's rulings: on a node whose `channels` include both
`inbox` and `email`, an address no user holds is mailed as written
(ruling B), and the inbox delivery for it still writes a row whose
`user_id` is the address. Filed as #25990.
- `@objectstack/spec` describe text for `recipients`
(`io-node-config.zod.ts`) still says a written-out address goes "to the
user who holds it, or else, on the `email` channel, to the address
itself" and does not say a node without `email` fails. That is the
`domain:spec` half's text.
- Branch is the harness-assigned `claude/project-thread-hdpxcs`; the
claim comment `6408881630` is the identity record.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(service-automation)!: a `notify` node that does not name the `email` channel fails on an address no user holds, written out or computed, instead of reporting success
6
+
7
+
Clause-②: no (narrowing: a `notify` node whose `channels` do not include `email`, and whose `recipients` hold an email address no user holds, written out or evaluated by a value expression, bare or as `user:ADDRESS`, now fails the node at run time instead of reporting success)
8
+
Metadata-protocol: none
9
+
10
+
An email address written out in `recipients` is the author's constant and is sent as written, and only the email channel sends to an address itself. The inbox delivers to a user, and so does the sms channel (or to a literal phone number). So on a node whose `channels` do not include `email` (the inbox, the default, or `inbox` or `sms` named), an address reaches someone only when a user holds it, wherever the address came from.
11
+
12
+
-**BREAKING (run time): such a node now fails when no user holds the address, written out or computed.** Before, the address went on as it stood: the inbox wrote a `sys_inbox_message` row whose `user_id` was the address, which no inbox reads, and the node reported success. Now the node fails before anything is written, and the message names the node, each such recipient and the fix. A written-out address is named; one a value expression (a CEL envelope) evaluated to is named by the expression's source, never by its value. `user:ADDRESS` fails the same way, because `user:` takes a user id and the address is handed on as one with no lookup. With no data engine to look the address up, or a lookup that fails, the node fails too.
13
+
-**Unchanged.** An address a user holds, written bare, still reaches that user. A node whose `channels` include `email` sends a written-out address as written, as before. On a node whose `channels` include `email`, a computed address no user holds still fails through the ruling-B screen, pointing to `{ emailField }`. The node never adds the `email` channel its author did not declare.
14
+
-**Who moves.** A flow whose `notify` node does not name `email` and writes out an address no user holds, or whose recipient expression evaluates to one (for example a record field holding an address that is not a user's). Fix: add `email` to the node's `channels` to mail a written-out address, or name a user: a user id, an audience selector such as `role:NAME`, or the address that user holds, written bare. A computed address of a person who is not a user goes to its own node with `recipients: { emailField: 'FIELD' }` and `channels: 'email'`.
15
+
-**Who moves in this repository (app-showcase).**
16
+
-`alert_exec` (flow `showcase_project_escalation`) wrote out `exec@example.com`, which no user holds, on the inbox. It now notifies `role:owner`.
17
+
-`notify_cleared` (flow `showcase_invoice_signoff`, the seeded Invoice Dual Sign-off demo) notified `record.owner` on the inbox. The seeded invoices' owners (`ada@`, `linus@` and `grace@example.com`) are deliberately not users: they are the fixture of the ADR-0055 isolation demo, where an operator signs up as one of them, so provisioning them would delete that demo. Approving a seeded invoice would now have failed the run at this node. It now notifies `role:owner`, the Default Organization's admin on a dev boot, who also holds the finance and legal positions that clear the request. `approval-resume-relation-expand.test.ts` approves the seeded INV-1010 through it.
18
+
- Its other inbox nodes whose expressions read `record.owner`, `record.assignee` or `ownerId` (projects, tasks) evaluate to addresses its seeds provision as users (pinned by `inert-wirings.test.ts`), so they keep delivering.
19
+
- Four nodes write out `admin@objectos.ai` on the inbox: `digest` (`showcase_scheduled_digest`), `log_fail` (`showcase_project_escalation`), `notify_done` (`showcase_release_signoff`) and `report` (`showcase_inquiry_purge`). On a dev boot, where `plugin-auth` seeds that admin, they deliver as before. On a boot without that user they now fail loud, where before they wrote an inbox row nobody reads.
20
+
21
+
<!-- adr-0087: not-required (no-migration-prescription) a run-time refusal in a `notify` node whose `channels` do not include `email` of a recipient, written out or computed, that is an email address no user holds: no authorable key, spelling or stored shape moves, the parse is unchanged, and no stored flow is read, rewritten or converted. The repair is the author's choice between mailing the address (adding `email` to `channels`) and naming a user, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
0 commit comments