Skip to content

Commit b0384f3

Browse 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>
1 parent a3e5bfa commit b0384f3

7 files changed

Lines changed: 479 additions & 33 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
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). -->

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,15 +1344,19 @@ export const InvoiceDualSignoffFlow = defineFlow({
13441344
label: 'Notify: Cleared',
13451345
config: {
13461346
topic: 'invoice.signoff',
1347-
// The INVOICE's own owner, not `record.account.owner` (#7381).
1348-
// `showcase_account` has no `owner` field at all — its people-ish keys
1349-
// are `billing_email` and the injected `owner_id` — so that hop resolved
1350-
// to nothing however the relation was hydrated, and the notify node
1351-
// refuses a run with no recipients: approving the demo stranded its run
1352-
// instead of delivering this message. `showcase_invoice.owner` is the
1353-
// seeded rep (the same RLS anchor the contributor permission set uses),
1354-
// which is who "your invoice cleared sign-off" is addressed to anyway.
1355-
recipients: [{ dialect: 'cel', source: 'has(record.owner) ? record.owner : null' }],
1347+
// [#25758] The organization's owners (`sys_member.role` `owner`), the
1348+
// Default Organization's admin on a dev boot, who also holds the
1349+
// finance and legal positions that clear the seeded request.
1350+
//
1351+
// Not `record.owner`: the seeded invoices' owners (ada@, linus@,
1352+
// grace@example.com) are deliberately NOT users — they are the
1353+
// fixture of the ADR-0055 isolation demo, where an operator signs UP
1354+
// as one of them (`demo-personas.ts`, `inert-wirings.test.ts` §5) —
1355+
// and an inbox-only node fails on an address no user holds, so
1356+
// approving the seeded invoice stranded the run here. Not
1357+
// `record.account.owner` either (#7381): `showcase_account` has no
1358+
// `owner` field, so that hop resolved to nothing.
1359+
recipients: 'role:owner',
13561360
channels: ['inbox'],
13571361
severity: 'info',
13581362
// The expanded relation is read HERE — `{{ record.account.name }}` is what
@@ -1432,7 +1436,12 @@ export const ProjectEscalationFlow = defineFlow({
14321436
},
14331437
{
14341438
name: 'Exec',
1435-
nodes: [{ id: 'alert_exec', type: 'notify', label: 'Alert Exec', config: { recipients: 'exec@example.com', title: '🔴 Critical project: {{ record.name }}', severity: 'critical', sourceObject: 'showcase_project', sourceId: { dialect: 'cel', source: 'record.id' } } }],
1439+
// [#25758] The organization's owners (`sys_member.role` `owner`):
1440+
// the Default Organization's is its admin. This inbox-only node
1441+
// wrote out `exec@example.com`, which no user holds, so the branch
1442+
// reached nobody; such a node now fails, and only `channels: 'email'`
1443+
// sends to an address itself.
1444+
nodes: [{ id: 'alert_exec', type: 'notify', label: 'Alert Exec', config: { recipients: 'role:owner', title: '🔴 Critical project: {{ record.name }}', severity: 'critical', sourceObject: 'showcase_project', sourceId: { dialect: 'cel', source: 'record.id' } } }],
14361445
edges: [],
14371446
},
14381447
],

‎examples/app-showcase/test/approval-resume-relation-expand.test.ts‎

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ import {
5858
TaskDoneNotifyOwnerFlow,
5959
NotifyOwnerSubflow,
6060
} from '../src/automation/flows/index.js';
61-
import { installDefaultOrganization } from './default-organization.testkit.js';
61+
import { ShowcaseSeedData } from '../src/data/seed/index.js';
62+
import { DEFAULT_ORGANIZATION_ID, installDefaultOrganization } from './default-organization.testkit.js';
6263

6364
/** Everything the approvals service treats as an unconditional actor. */
6465
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
@@ -85,6 +86,62 @@ afterEach(async () => {
8586
}
8687
});
8788

89+
/** The dev admin: the Default Organization's `owner`, who clears the seeded invoice. */
90+
const ADMIN = { id: 'usr_admin', email: 'admin@objectos.ai', name: 'Admin' };
91+
/** A provisioned demo persona, owning the project the sweep test hops through. */
92+
const MEI = { id: 'usr_showcase_phone_demo', email: 'phone.demo@example.com', name: 'Mei Phone (demo)' };
93+
94+
/**
95+
* [#25758] The directory a dev boot has: the admin, an `owner` member of the
96+
* Default Organization, and a provisioned persona. An inbox-only `notify`
97+
* delivers an address only to the user who holds it, and `role:owner` expands
98+
* through `sys_member`. The seeded invoice owners (grace@example.com here) are
99+
* deliberately not users, as on a dev boot. Seeded through the driver, like
100+
* the Default Organization: data at rest, not a write under test.
101+
*/
102+
async function installDirectory(engine: any): Promise<void> {
103+
if (!engine.getSchema('sys_user')) {
104+
engine.registry.registerObject(
105+
{
106+
name: 'sys_user',
107+
label: 'User',
108+
managedBy: 'better-auth',
109+
fields: { email: { type: 'email', label: 'Email' }, name: { type: 'text', label: 'Name' } },
110+
},
111+
'@objectstack/example-showcase',
112+
);
113+
}
114+
if (!engine.getSchema('sys_member')) {
115+
engine.registry.registerObject(
116+
{
117+
name: 'sys_member',
118+
label: 'Member',
119+
managedBy: 'better-auth',
120+
fields: {
121+
user_id: { type: 'text', label: 'User' },
122+
role: { type: 'text', label: 'Role' },
123+
organization_id: { type: 'text', label: 'Organization' },
124+
},
125+
},
126+
'@objectstack/example-showcase',
127+
);
128+
}
129+
await engine.syncSchemas();
130+
const users = engine.getDriverForObject('sys_user')!;
131+
for (const user of [ADMIN, MEI]) {
132+
if (!(await users.findOne('sys_user', { where: { id: user.id } }))) await users.create('sys_user', user);
133+
}
134+
const members = engine.getDriverForObject('sys_member')!;
135+
if (!(await members.findOne('sys_member', { where: { id: 'mem_admin_owner' } }))) {
136+
await members.create('sys_member', {
137+
id: 'mem_admin_owner',
138+
user_id: ADMIN.id,
139+
role: 'owner',
140+
organization_id: DEFAULT_ORGANIZATION_ID,
141+
});
142+
}
143+
}
144+
88145
interface Booted {
89146
automation: AutomationEngine & Record<string, any>;
90147
approvals: any;
@@ -132,6 +189,7 @@ async function bootShowcaseApprovals(
132189
// A real `single` install's Default Organization: the system writes below
133190
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
134191
await installDefaultOrganization(objectql);
192+
await installDirectory(objectql);
135193

136194
for (const flow of flows) automation.registerFlow(flow.name, flow);
137195

@@ -342,7 +400,15 @@ function inboxSettled(booted: Booted, flowName: string, status: string): () => P
342400
};
343401
}
344402

345-
/** Insert the demo's account + a `sent` invoice owned by a real rep. */
403+
/**
404+
* The app's own seed row for INV-1010, a `sent` invoice: what a dev boot
405+
* stores and the approval demo opens a sign-off on. [#25758] Its owner is one
406+
* of the ADR-0055 isolation fixture's addresses, which no user holds.
407+
*/
408+
const SEEDED_INV_1010 = (ShowcaseSeedData.find((d: any) => d.object === 'showcase_invoice') as any)
409+
.records.find((r: any) => r.name === 'INV-1010');
410+
411+
/** Insert the demo's account + the seeded `sent` invoice INV-1010. */
346412
async function seedInvoice(data: any): Promise<{ invoice: any; account: any }> {
347413
const account = await one(await data.insert('showcase_account', {
348414
name: 'Fabrikam',
@@ -352,10 +418,10 @@ async function seedInvoice(data: any): Promise<{ invoice: any; account: any }> {
352418
}, SYS));
353419

354420
const invoice = await one(await data.insert('showcase_invoice', {
355-
name: 'INV-1010',
421+
name: SEEDED_INV_1010.name,
356422
account: account.id,
357-
owner: 'grace@example.com',
358-
status: 'sent',
423+
owner: SEEDED_INV_1010.owner,
424+
status: SEEDED_INV_1010.status,
359425
issued_on: '2026-05-27',
360426
tax_rate: 20,
361427
region: 'emea',
@@ -393,7 +459,7 @@ async function pendingRequest(data: any): Promise<any> {
393459
}
394460

395461
describe('#7381 — approving Invoice Dual Sign-off delivers the cleared notice', () => {
396-
it('satisfies both unanimous slots and runs through to end_ok with the inbox message delivered', async () => {
462+
it('approving the seeded INV-1010, whose owner is not a user, runs through to end_ok with the inbox message delivered', async () => {
397463
const booted = await bootShowcaseApprovals();
398464
const { invoice, account } = await seedInvoice(booted.data);
399465

@@ -455,10 +521,10 @@ describe('#7381 — approving Invoice Dual Sign-off delivers the cleared notice'
455521

456522
const notice = delivered[0];
457523
expect(notice.title).toBe('Invoice cleared: INV-1010');
458-
// The recipient is the INVOICE's owner — the field that actually holds a
459-
// person. This is the assertion the shipped `{record.account.owner}` could
460-
// never satisfy, because `showcase_account` has no such field.
461-
expect(notice.user_id).toBe('grace@example.com');
524+
// [#25758] The seeded invoice's owner holds no account, as on a dev boot;
525+
// the cleared notice reaches the organization's owner, a real user.
526+
expect(await booted.data.findOne('sys_user', { where: { email: invoice.owner }, ...SYS })).toBeFalsy();
527+
expect(notice.user_id).toBe(ADMIN.id);
462528
// The start node's `expand: ['account']` is what makes this readable — an
463529
// un-hydrated relation renders the raw FK, or nothing at all.
464530
expect(notice.body_md).toContain(account.name);
@@ -613,7 +679,7 @@ describe('#7381 — approving Invoice Dual Sign-off delivers the cleared notice'
613679
name: 'Compliance Audit',
614680
account: account.id,
615681
status: 'planned',
616-
owner: 'grace@example.com',
682+
owner: MEI.email,
617683
}, SYS));
618684

619685
const task = one(await booted.data.insert('showcase_task', {
@@ -638,7 +704,7 @@ describe('#7381 — approving Invoice Dual Sign-off delivers the cleared notice'
638704
// annotation is free to name the only other way it can fail (#10106).
639705
const delivered = await deliveredInbox(booted, 'Project update', 1);
640706
expect(delivered, 'the subflow notified more than one recipient').toHaveLength(1);
641-
expect(delivered[0].user_id, 'the recipient is not the project owner the hop names').toBe('grace@example.com');
707+
expect(delivered[0].user_id, 'the recipient is not the project owner the hop names').toBe(MEI.id);
642708
}, 30_000);
643709
});
644710

‎packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,13 +330,17 @@ describe('#25647 — a computed recipient address must be a user', () => {
330330
it.each([
331331
['names no channel (inbox, the default)', undefined],
332332
['names inbox only', ['inbox']],
333-
])('E5: a node that %s mails nobody, so its computed address goes to messaging as before', async (_label, channels) => {
333+
])('E5: a node that %s mails nobody, so the screen does not run; its computed address must still reach a user (#25758)', async (_label, channels) => {
334334
const { engine, emitted } = bootUnchecked();
335335
engine.registerFlow('applicant_notice', notifyFlow({ recipients: [CEL('record.contact_text')], channels }));
336336
const run = await engine.execute('applicant_notice', onCreate('lead_1', LEAD));
337-
expect(run.success).toBe(true);
338-
expect(emitted).toHaveLength(1);
339-
expect(emitted[0].audience).toEqual(['stranger@outside.example']);
337+
// No directory to check it: refused as undeliverable, not by the mail screen.
338+
expect(run.success).toBe(false);
339+
expect(emitted).toHaveLength(0);
340+
const runs = await engine.listRuns('applicant_notice');
341+
const message = runs[0]?.steps.find((s) => s.nodeId === 'notify')?.error?.message ?? '';
342+
expect(message).toContain('Only the email channel sends to an address');
343+
expect(message).not.toContain('A computed recipient must be a user');
340344
});
341345

342346
it('E6: a node whose channel list includes email screens its computed address', async () => {

0 commit comments

Comments
 (0)