From da686d76d23a6b9caf57c474f4befc029f383b8e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:26:50 +0000 Subject: [PATCH 1/3] test(service-automation): a both-channels notify node writes an inbox row for an unheld written-out address (red) Red on main b0384f3f: 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 --- ...hannels-unheld-address.integration.test.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts diff --git a/packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts b/packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts new file mode 100644 index 00000000000..302211b8e38 --- /dev/null +++ b/packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + MessagingService, + NotificationTemplateStore, + createEmailChannel, + createInboxChannel, + INBOX_OBJECT, + NOTIFICATION_EVENT_OBJECT, +} from '@objectstack/service-messaging'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { AutomationEngine } from '../engine.js'; +import { registerNotifyNode } from './notify-node.js'; + +/** + * [#25990] On a node whose `channels` name `email` AND another channel, an + * address written out in `recipients` that no user holds is mailed as written + * (ruling 6386629237 letter B). The other channels deliver to a user: the inbox + * keys its row by the recipient as a user id. So the address goes to the email + * channel only, and the inbox writes no row for it; before, it wrote a + * `sys_inbox_message` row whose `user_id` was the address, which no inbox reads. + * + * The node still succeeds when the email delivery succeeds, and an address a + * user holds keeps reaching that user's inbox and is mailed as before. + * + * Wired through the REAL `MessagingService` (inline fan-out) and the REAL + * inbox and email channels, over a data engine that stores the directory and + * records every row the stack writes. + */ + +function silentLogger(): any { + const l: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + l.child = () => l; + return l; +} + +interface Row { [key: string]: unknown; id: string } + +/** A data engine holding the users, and recording every row the stack writes. */ +function dataEngine(users: Row[], opts: { failUserLookup?: boolean } = {}) { + const written: Array<{ object: string; row: Record }> = []; + let seq = 0; + const tables: Record = { sys_user: users }; + const engine = { + async insert(object: string, row: Record) { + const stored = { ...row, id: row.id != null ? String(row.id) : `row_${++seq}` }; + written.push({ object, row: stored }); + return stored; + }, + async find() { return []; }, + async findOne(object: string, query?: EngineFindOneQueryInput) { + assertEngineFindOnePredicate(object, query); + const where = (query as { where?: Record } | undefined)?.where ?? {}; + if (opts.failUserLookup && object === 'sys_user' && 'email' in where) throw new Error('directory offline'); + // Equality on plain fields only: a combinator or operator is refused, never read as a field name. + const hit = (tables[object] ?? []).find((r) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$') || (typeof v === 'object' && v !== null)) { + throw new Error(`this double matches equality on plain fields only: ${JSON.stringify(where)}`); + } + return r[k] === v; + })); + return hit ? { ...hit } : undefined; + }, + }; + return { engine, written }; +} + +const EXEC = { id: 'usr_exec', email: 'exec@company.example' }; + +/** The stack: messaging with the inbox and email channels, and the notify node over them. */ +function boot(opts: { failUserLookup?: boolean } = {}) { + const { engine: data, written } = dataEngine([EXEC], opts); + const getData = () => data as any; + const mailed: unknown[] = []; + const messaging = new MessagingService({ logger: silentLogger(), getData }); + messaging.registerChannel(createInboxChannel({ getData })); + messaging.registerChannel(createEmailChannel({ + getEmail: () => ({ + async send(input) { + mailed.push(input.to); + return { id: `mail_${mailed.length}` }; + }, + }), + getData, + store: new NotificationTemplateStore({ getData: () => undefined }), + })); + const engine = new AutomationEngine(silentLogger()); + registerNotifyNode(engine, { + logger: silentLogger(), + getService: (name: string) => (name === 'messaging' ? messaging : name === 'data' ? getData() : undefined), + } as any); + return { engine, written, mailed }; +} + +function notifyFlow(config: Record): any { + return { + name: 'escalation', + label: 'Escalation', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'alert_exec', type: 'notify', label: 'Alert Exec', config: { title: 'Critical', ...config } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'alert_exec' }, + { id: 'e2', source: 'alert_exec', target: 'end' }, + ], + }; +} + +async function runOnce(stack: ReturnType, config: Record, record?: Record) { + stack.engine.registerFlow('escalation', notifyFlow(config)); + const context = record ? { tenantId: 'org_1', object: 'crm_lead', record } : { tenantId: 'org_1' }; + const run = await stack.engine.execute('escalation', context as any); + const runs = await stack.engine.listRuns('escalation'); + const step = runs[0]?.steps.find((s) => s.nodeId === 'alert_exec'); + return { run, step }; +} + +const rowsOf = (stack: ReturnType, object: string) => + stack.written.filter((w) => w.object === object).map((w) => w.row); + +const BOTH = ['inbox', 'email']; + +describe('#25990 — a written-out address no user holds, on a node naming inbox and email', () => { + it.each([ + ['bare', 'exec@example.com'], + ['as user:ADDRESS', 'user:exec@example.com'], + ['as user:ADDRESS of an address a user holds', 'user:exec@company.example'], + ])('is mailed as written and writes no inbox row (%s)', async (_label, recipient) => { + const stack = boot(); + const { run, step } = await runOnce(stack, { recipients: [recipient], channels: BOTH }); + expect(run.success).toBe(true); + expect(stack.mailed).toEqual([recipient.replace(/^user:/, '')]); + expect(rowsOf(stack, INBOX_OBJECT)).toEqual([]); + expect((step?.output as { delivered?: number } | undefined)?.delivered).toBe(1); + }); + + it('beside a user, the user still gets the inbox row and the mail; the address only the mail', async () => { + const stack = boot(); + const { run, step } = await runOnce(stack, { recipients: ['usr_exec', 'stranger@example.com'], channels: BOTH }); + expect(run.success).toBe(true); + expect(rowsOf(stack, INBOX_OBJECT).map((r) => r.user_id)).toEqual(['usr_exec']); + expect([...stack.mailed].sort()).toEqual(['exec@company.example', 'stranger@example.com']); + // Every delivery a channel accepted is counted: the user's inbox and mail, and the address's mail. + expect((step?.output as { delivered?: number } | undefined)?.delivered).toBe(3); + expect(rowsOf(stack, INBOX_OBJECT).every((r) => r.user_id === 'usr_exec')).toBe(true); + expect(rowsOf(stack, NOTIFICATION_EVENT_OBJECT).length).toBeGreaterThan(0); + }); + + it('a written-out address a user holds keeps reaching that user\'s inbox and is mailed, as before', async () => { + const stack = boot(); + const { run } = await runOnce(stack, { recipients: ['exec@company.example'], channels: BOTH }); + expect(run.success).toBe(true); + expect(rowsOf(stack, INBOX_OBJECT).map((r) => r.user_id)).toEqual(['usr_exec']); + expect(stack.mailed).toEqual(['exec@company.example']); + }); + + it('when the directory cannot say who holds it, the address is still mailed and never keys an inbox row', async () => { + const stack = boot({ failUserLookup: true }); + const { run } = await runOnce(stack, { recipients: ['exec@example.com'], channels: BOTH }); + expect(run.success).toBe(true); + expect(stack.mailed).toEqual(['exec@example.com']); + expect(rowsOf(stack, INBOX_OBJECT)).toEqual([]); + }); + + it('a computed address a user holds reaches that user\'s inbox on a both-channels node', async () => { + const stack = boot(); + const { run } = await runOnce( + stack, + { recipients: { dialect: 'cel', source: 'record.owner_email' }, channels: BOTH }, + { id: 'lead_1', owner_email: 'exec@company.example' }, + ); + expect(run.success).toBe(true); + expect(rowsOf(stack, INBOX_OBJECT).map((r) => r.user_id)).toEqual(['usr_exec']); + expect(stack.mailed).toEqual(['exec@company.example']); + }); +}); From 1ce55062a73b128a3b06e26f1a69c86a1c4ff9b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:29:19 +0000 Subject: [PATCH 2/3] fix(service-automation): a notify node naming email and another channel 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 --- ...990-notify-both-channels-unheld-address.md | 15 +++ ...hannels-unheld-address.integration.test.ts | 16 ++- .../src/builtin/notify-node.ts | 120 +++++++++++++++--- 3 files changed, 127 insertions(+), 24 deletions(-) create mode 100644 .changeset/25990-notify-both-channels-unheld-address.md diff --git a/.changeset/25990-notify-both-channels-unheld-address.md b/.changeset/25990-notify-both-channels-unheld-address.md new file mode 100644 index 00000000000..a17d3dcb8b6 --- /dev/null +++ b/.changeset/25990-notify-both-channels-unheld-address.md @@ -0,0 +1,15 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation): a `notify` node naming `email` and another channel mails an address no user holds and no longer writes an inbox row addressed to it + +Clause-②: no +Metadata-protocol: none + +An email address written out in `recipients` is the author's constant: on a node whose `channels` name `email` it is mailed as written. The inbox delivers to a user, keying its row by the recipient as a user id, and so does every channel but `email`. So on a node whose `channels` name `email` and another channel, such as `['inbox', 'email']`, an address no user holds now goes to the email channel alone. + +- **Fixed.** Before, the address went to every channel as it stood: the email channel mailed it, and the inbox also wrote a `sys_inbox_message` row whose `user_id` was the address, which no inbox reads. Now the node emits that address apart, on the `email` channel only, and the inbox writes no row for it. `user:ADDRESS` is handled the same way, whoever holds the address, because `user:` hands the address on as the user id with no lookup. An address whose holder cannot be looked up (no data engine, a lookup that fails) is mailed alone too. +- **Unchanged.** The node succeeds as before. An address a user holds, written bare, still reaches that user's inbox and is mailed. A value expression that evaluates to an address on a node naming `email` still resolves to the user who holds it, or fails the node pointing to `{ emailField }`. A node that does not name `email` still fails on an address no user holds. +- **What a run records.** When a node's recipients mix users and such addresses, the node writes two `sys_notification` events: one for the users on the node's `channels`, which the node's `notificationId` output names, and one for the addresses on `email` alone. The `delivered`, `enqueued` and `failed` outputs add up both. +- **Who moves.** Nobody needs to change a flow. A report or audit over `sys_inbox_message` no longer sees rows whose `user_id` is an address. diff --git a/packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts b/packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts index 302211b8e38..cbdd1d74aac 100644 --- a/packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts +++ b/packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts @@ -131,23 +131,25 @@ describe('#25990 — a written-out address no user holds, on a node naming inbox ['as user:ADDRESS of an address a user holds', 'user:exec@company.example'], ])('is mailed as written and writes no inbox row (%s)', async (_label, recipient) => { const stack = boot(); - const { run, step } = await runOnce(stack, { recipients: [recipient], channels: BOTH }); + const { run } = await runOnce(stack, { recipients: [recipient], channels: BOTH }); expect(run.success).toBe(true); expect(stack.mailed).toEqual([recipient.replace(/^user:/, '')]); expect(rowsOf(stack, INBOX_OBJECT)).toEqual([]); - expect((step?.output as { delivered?: number } | undefined)?.delivered).toBe(1); + // The one recipient, and the one delivery a channel accepted: its mail. + expect(run.summary).toMatchObject({ selected: 1, acted: 1 }); }); it('beside a user, the user still gets the inbox row and the mail; the address only the mail', async () => { const stack = boot(); - const { run, step } = await runOnce(stack, { recipients: ['usr_exec', 'stranger@example.com'], channels: BOTH }); + const { run } = await runOnce(stack, { recipients: ['usr_exec', 'stranger@example.com'], channels: BOTH }); expect(run.success).toBe(true); expect(rowsOf(stack, INBOX_OBJECT).map((r) => r.user_id)).toEqual(['usr_exec']); expect([...stack.mailed].sort()).toEqual(['exec@company.example', 'stranger@example.com']); - // Every delivery a channel accepted is counted: the user's inbox and mail, and the address's mail. - expect((step?.output as { delivered?: number } | undefined)?.delivered).toBe(3); - expect(rowsOf(stack, INBOX_OBJECT).every((r) => r.user_id === 'usr_exec')).toBe(true); - expect(rowsOf(stack, NOTIFICATION_EVENT_OBJECT).length).toBeGreaterThan(0); + // Both recipients are counted, and every delivery a channel accepted: + // the user's inbox row and mail, and the address's mail. + expect(run.summary).toMatchObject({ selected: 2, acted: 3 }); + // The address's mail is its own notification, on the email channel alone. + expect(rowsOf(stack, NOTIFICATION_EVENT_OBJECT)).toHaveLength(2); }); it('a written-out address a user holds keeps reaching that user\'s inbox and is mailed, as before', async () => { diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 52a0de75d43..e7907826b21 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -272,6 +272,26 @@ const USER_OBJECT = 'sys_user'; */ const RECIPIENT_READ = { context: { isSystem: true } } as const; +/** + * The id of the user who holds `address`, or `undefined` when no user does — + * the lookup the messaging resolver makes (`sys_user` by `email`, the id + * projected), so "a user holds it" means here what it means there. Throws when + * the lookup fails. The one test every address check below makes. + */ +async function holderOf(data: IDataEngine, address: string): Promise { + const user = await data.findOne(USER_OBJECT, { where: { email: address }, fields: ['id'] }, RECIPIENT_READ); + return toStr(user?.id); +} + +/** + * True when `entry` hands its address on as the recipient id, as `user:a@b.c` + * does: the messaging resolver passes the remainder of a `user:` value on + * with no lookup, so it never reaches the user who holds the address. + */ +function namesAddressAsUserId(entry: AddressedRecipient): boolean { + return entry.recipient !== entry.address; +} + /** A recipient a value envelope evaluated to that reads as an email address. */ interface ComputedAddress { /** Its position in the resolved recipient list. */ @@ -372,16 +392,15 @@ async function screenComputedAddresses( + 'whether a user holds that address, so nothing was sent.', }; } - let user: Record | null | undefined; + let id: string | undefined; try { - user = await data.findOne(USER_OBJECT, { where: { email: address }, fields: ['id'] }, RECIPIENT_READ); + id = await holderOf(data, address); } catch (err) { return { ok: false, error: `${located}. Looking up the user who holds it failed (${(err as Error).message}), so nothing was sent.`, }; } - const id = toStr(user?.id); if (id === undefined) { return { ok: false, @@ -448,7 +467,7 @@ async function refuseUndeliverableAddresses( : `\`${entry.recipient}\``); const undeliverable: string[] = []; for (const entry of addressed) { - if (entry.recipient !== entry.address) { + if (namesAddressAsUserId(entry)) { undeliverable.push(`${nameOf(entry)} (\`user:\` takes a user id, not an email address)`); continue; } @@ -459,14 +478,14 @@ async function refuseUndeliverableAddresses( return `${lead}, and there is no data engine to look up whether a user holds it, so nothing was sent. ` + fixFor(entry.computed !== undefined); } - let user: Record | null | undefined; + let holder: string | undefined; try { - user = await data.findOne(USER_OBJECT, { where: { email: entry.address }, fields: ['id'] }, RECIPIENT_READ); + holder = await holderOf(data, entry.address); } catch (err) { return `${lead}, and looking up the user who holds it failed (${(err as Error).message}), so nothing was ` + `sent. ${fixFor(entry.computed !== undefined)}`; } - if (toStr(user?.id) === undefined) undeliverable.push(`${nameOf(entry)} (no user holds that address)`); + if (holder === undefined) undeliverable.push(`${nameOf(entry)} (no user holds that address)`); } if (undeliverable.length === 0) return undefined; const one = undeliverable.length === 1; @@ -476,6 +495,51 @@ async function refuseUndeliverableAddresses( + fixFor(addressed.some((entry) => entry.computed !== undefined)); } +/** + * [#25990] On a node whose `channels` name `email` AND another channel, the + * recipients written out as an address that only the email channel can + * deliver: each one the email channel sends to as written (ruling B on + * #25647), and no other channel is handed it. + * + * The other channels deliver to a user, so an address reaches them only + * through the user who holds it, by the test {@link refuseUndeliverableAddresses} + * makes: `user:a@b.c` never does (the resolver hands the remainder on as the + * id), and a bare address does when a user holds it. Before, an address that + * did not went on to every channel as it stood: the email channel mailed it, + * and the inbox wrote a row whose `user_id` was the address, which no inbox + * reads. An address whose holder could not be looked up (no data engine, a + * lookup that fails) goes to the email channel alone too: the email channel + * mails it as written either way, and an inbox row keyed by it would reach + * nobody, so only the mail is sure to reach anyone. + * + * A computed address is not here: {@link screenComputedAddresses} already + * turned one a user holds into that user's id, and failed the node on any + * other. The skip lives in the node, not in a channel, because the node is + * the one place that knows a recipient was written out as an address; every + * channel keeps reading a recipient as it does today. + */ +async function mailOnlyAddresses( + data: IDataEngine | undefined, + addressed: readonly AddressedRecipient[], +): Promise> { + const mailOnly = new Set(); + for (const entry of addressed) { + if (entry.computed !== undefined) continue; + if (namesAddressAsUserId(entry) || !data) { + mailOnly.add(entry.recipient); + continue; + } + let holder: string | undefined; + try { + holder = await holderOf(data, entry.address); + } catch { + holder = undefined; + } + if (holder === undefined) mailOnly.add(entry.recipient); + } + return mailOnly; +} + /** * True when `value` is ONE email address and nothing else — the check the * address an email-field recipient reads must pass before it is mailed. @@ -733,7 +797,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // Studio form keeps its recipients picker, and the marker // rides `NotifyConfigSchema` (`LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`). recipients: { - description: 'Who the notification goes to: a user id or an audience selector (such as team:ID) as a string, or a list of them — each entry a string or a CEL value envelope { dialect: \'cel\', source } evaluated per run; an entry that evaluates to a list adds each of its recipients, and with channels: email an address it evaluates to must be a user\'s. An email address written out is sent as written: with channels: email to the address itself, and otherwise to the user who holds it, the node failing when no user does — as it does for an address an entry evaluates to. A single-brace {token} is refused. To mail a person who is not a user, use { emailField: \'FIELD\' } as the whole value, naming an email field of the record that triggered the flow, with channels: email; at most 5 such emails go to one address per hour.', + description: 'Who the notification goes to: a user id or an audience selector (such as team:ID) as a string, or a list of them — each entry a string or a CEL value envelope { dialect: \'cel\', source } evaluated per run; an entry that evaluates to a list adds each of its recipients, and with channels: email an address it evaluates to must be a user\'s. An email address written out is sent as written: with channels: email to the address itself, and otherwise to the user who holds it, the node failing when no user does — as it does for an address an entry evaluates to. When channels name email and another channel, an address no user holds is mailed alone: the other channels deliver to a user, so they are not handed it. A single-brace {token} is refused. To mail a person who is not a user, use { emailField: \'FIELD\' } as the whole value, naming an email field of the record that triggered the flow, with channels: email; at most 5 such emails go to one address per hour.', }, // The form edits the bare-string spelling of these two // template slots; the contract (`NotifyConfigSchema`) also @@ -840,10 +904,13 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // Any other recipient an envelope computed that reads as an address // must be a user's when the node mails (`screenComputedAddresses`). // [#25758] Any address, written out or computed, must reach a - // user when it does not (`refuseUndeliverableAddresses`). + // user when it does not (`refuseUndeliverableAddresses`). [#25990] + // When it mails AND names another channel, a written-out address + // no user holds goes to the email channel alone (`mailOnlyAddresses`). const emailField = isEmailFieldRecipient(cfg.recipients) ? cfg.recipients.emailField : undefined; let recipients: string[]; let emptyRecipients: string[] = []; + let mailOnly: ReadonlySet = new Set(); if (emailField !== undefined) { const address = await resolveEmailFieldAddress(getData(), context, emailField); if (!address.ok) return { success: false, error: address.error }; @@ -864,6 +931,9 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) ); if (refused !== undefined) return { success: false, error: refused }; } + if (mails && toStringList(cfg.channels).some((channel) => channel !== 'email')) { + mailOnly = await mailOnlyAddresses(getData(), audience.value.addressed); + } recipients = screened.value; emptyRecipients = audience.value.empty; } @@ -1034,9 +1104,8 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // audience + payload; it writes the L2 event and materializes // per channel. title/body/url ride in the payload (templates in // a later phase fall back to these). - const result = await messaging.emit({ + const notification = { topic: topic ?? 'notify', - audience: recipients, // Content rides in the payload per path (#9205): the inline // strings, or the template reference + its render context — // which the outbox snapshots onto each delivery row, so the @@ -1064,21 +1133,38 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // `EmitInput.organizationId` is optional, and the engine // decides the owner of an emit that carries none (above). ...(organizationId ? { organizationId } : {}), - channels: channels.length ? channels : undefined, - }); - const delivered = Number(result.delivered) || 0; - const enqueued = Number(result.enqueued) || 0; + }; + // [#25990] A written-out address only the email channel can + // deliver is emitted apart, on that channel alone, so no other + // channel is handed it (`mailOnlyAddresses`). Every other + // recipient is emitted as before, on the node's `channels`; its + // notification is the one `notificationId` names. The counts + // add up both emits. + const everyChannel = recipients.filter((recipient) => !mailOnly.has(recipient)); + const mailedOnly = recipients.filter((recipient) => mailOnly.has(recipient)); + const results: Array>> = []; + if (everyChannel.length > 0) { + results.push(await messaging.emit({ + ...notification, audience: everyChannel, channels: channels.length ? channels : undefined, + })); + } + if (mailedOnly.length > 0) { + results.push(await messaging.emit({ ...notification, audience: mailedOnly, channels: ['email'] })); + } + const delivered = results.reduce((sum, result) => sum + (Number(result.delivered) || 0), 0); + const enqueued = results.reduce((sum, result) => sum + (Number(result.enqueued) || 0), 0); + const failed = results.reduce((sum, result) => sum + (Number(result.failed) || 0), 0); return { success: true, output: { - notificationId: result.notificationId, + notificationId: results[0]?.notificationId, delivered, // Surfaced so a flow author templating the outcome can // tell "sent" from "handed to the outbox", and so the // notification id above has a stated reason to be // followed into `sys_notification_delivery`. enqueued, - failed: result.failed, + failed, }, // A notification IS the action for a nudge/alert sweep, so it // counts toward `acted` (#4354) — otherwise the flow whose From 745715539041813bf60dc8370647e742ea5a4709 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:59:12 +0000 Subject: [PATCH 3/3] test(service-automation): pin the both-channels test's findOne double in the engine-double ledger Written by check:engine-double-contract --write. Claude-Session: https://claude.ai/code/session_01617Hq2GbkLYhXsfVgtvNuR Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 662ae6961c5..07cf3b05c6c 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4476,6 +4476,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-automation/src/builtin/notify-both-channels-unheld-address.integration.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts", "verb": "findOne",