Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/25758-notify-written-address-no-email-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/service-automation': patch
---

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

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)
Metadata-protocol: none

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.

- **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.
- **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.
- **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'`.
- **Who moves in this repository (app-showcase).**
- `alert_exec` (flow `showcase_project_escalation`) wrote out `exec@example.com`, which no user holds, on the inbox. It now notifies `role:owner`.
- `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.
- 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.
- 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.

<!-- 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). -->
29 changes: 19 additions & 10 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1344,15 +1344,19 @@ export const InvoiceDualSignoffFlow = defineFlow({
label: 'Notify: Cleared',
config: {
topic: 'invoice.signoff',
// The INVOICE's own owner, not `record.account.owner` (#7381).
// `showcase_account` has no `owner` field at all — its people-ish keys
// are `billing_email` and the injected `owner_id` — so that hop resolved
// to nothing however the relation was hydrated, and the notify node
// refuses a run with no recipients: approving the demo stranded its run
// instead of delivering this message. `showcase_invoice.owner` is the
// seeded rep (the same RLS anchor the contributor permission set uses),
// which is who "your invoice cleared sign-off" is addressed to anyway.
recipients: [{ dialect: 'cel', source: 'has(record.owner) ? record.owner : null' }],
// [#25758] The organization's owners (`sys_member.role` `owner`), the
// Default Organization's admin on a dev boot, who also holds the
// finance and legal positions that clear the seeded request.
//
// Not `record.owner`: the seeded invoices' owners (ada@, linus@,
// 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 (`demo-personas.ts`, `inert-wirings.test.ts` §5) —
// and an inbox-only node fails on an address no user holds, so
// approving the seeded invoice stranded the run here. Not
// `record.account.owner` either (#7381): `showcase_account` has no
// `owner` field, so that hop resolved to nothing.
recipients: 'role:owner',
channels: ['inbox'],
severity: 'info',
// The expanded relation is read HERE — `{{ record.account.name }}` is what
Expand Down Expand Up @@ -1432,7 +1436,12 @@ export const ProjectEscalationFlow = defineFlow({
},
{
name: 'Exec',
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' } } }],
// [#25758] The organization's owners (`sys_member.role` `owner`):
// the Default Organization's is its admin. This inbox-only node
// wrote out `exec@example.com`, which no user holds, so the branch
// reached nobody; such a node now fails, and only `channels: 'email'`
// sends to an address itself.
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' } } }],
edges: [],
},
],
Expand Down
90 changes: 78 additions & 12 deletions examples/app-showcase/test/approval-resume-relation-expand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ import {
TaskDoneNotifyOwnerFlow,
NotifyOwnerSubflow,
} from '../src/automation/flows/index.js';
import { installDefaultOrganization } from './default-organization.testkit.js';
import { ShowcaseSeedData } from '../src/data/seed/index.js';
import { DEFAULT_ORGANIZATION_ID, installDefaultOrganization } from './default-organization.testkit.js';

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

/** The dev admin: the Default Organization's `owner`, who clears the seeded invoice. */
const ADMIN = { id: 'usr_admin', email: 'admin@objectos.ai', name: 'Admin' };
/** A provisioned demo persona, owning the project the sweep test hops through. */
const MEI = { id: 'usr_showcase_phone_demo', email: 'phone.demo@example.com', name: 'Mei Phone (demo)' };

/**
* [#25758] The directory a dev boot has: the admin, an `owner` member of the
* Default Organization, and a provisioned persona. An inbox-only `notify`
* delivers an address only to the user who holds it, and `role:owner` expands
* through `sys_member`. The seeded invoice owners (grace@example.com here) are
* deliberately not users, as on a dev boot. Seeded through the driver, like
* the Default Organization: data at rest, not a write under test.
*/
async function installDirectory(engine: any): Promise<void> {
if (!engine.getSchema('sys_user')) {
engine.registry.registerObject(
{
name: 'sys_user',
label: 'User',
managedBy: 'better-auth',
fields: { email: { type: 'email', label: 'Email' }, name: { type: 'text', label: 'Name' } },
},
'@objectstack/example-showcase',
);
}
if (!engine.getSchema('sys_member')) {
engine.registry.registerObject(
{
name: 'sys_member',
label: 'Member',
managedBy: 'better-auth',
fields: {
user_id: { type: 'text', label: 'User' },
role: { type: 'text', label: 'Role' },
organization_id: { type: 'text', label: 'Organization' },
},
},
'@objectstack/example-showcase',
);
}
await engine.syncSchemas();
const users = engine.getDriverForObject('sys_user')!;
for (const user of [ADMIN, MEI]) {
if (!(await users.findOne('sys_user', { where: { id: user.id } }))) await users.create('sys_user', user);
}
const members = engine.getDriverForObject('sys_member')!;
if (!(await members.findOne('sys_member', { where: { id: 'mem_admin_owner' } }))) {
await members.create('sys_member', {
id: 'mem_admin_owner',
user_id: ADMIN.id,
role: 'owner',
organization_id: DEFAULT_ORGANIZATION_ID,
});
}
}

interface Booted {
automation: AutomationEngine & Record<string, any>;
approvals: any;
Expand Down Expand Up @@ -132,6 +189,7 @@ async function bootShowcaseApprovals(
// A real `single` install's Default Organization: the system writes below
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
await installDefaultOrganization(objectql);
await installDirectory(objectql);

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

Expand Down Expand Up @@ -342,7 +400,15 @@ function inboxSettled(booted: Booted, flowName: string, status: string): () => P
};
}

/** Insert the demo's account + a `sent` invoice owned by a real rep. */
/**
* The app's own seed row for INV-1010, a `sent` invoice: what a dev boot
* stores and the approval demo opens a sign-off on. [#25758] Its owner is one
* of the ADR-0055 isolation fixture's addresses, which no user holds.
*/
const SEEDED_INV_1010 = (ShowcaseSeedData.find((d: any) => d.object === 'showcase_invoice') as any)
.records.find((r: any) => r.name === 'INV-1010');

/** Insert the demo's account + the seeded `sent` invoice INV-1010. */
async function seedInvoice(data: any): Promise<{ invoice: any; account: any }> {
const account = await one(await data.insert('showcase_account', {
name: 'Fabrikam',
Expand All @@ -352,10 +418,10 @@ async function seedInvoice(data: any): Promise<{ invoice: any; account: any }> {
}, SYS));

const invoice = await one(await data.insert('showcase_invoice', {
name: 'INV-1010',
name: SEEDED_INV_1010.name,
account: account.id,
owner: 'grace@example.com',
status: 'sent',
owner: SEEDED_INV_1010.owner,
status: SEEDED_INV_1010.status,
issued_on: '2026-05-27',
tax_rate: 20,
region: 'emea',
Expand Down Expand Up @@ -393,7 +459,7 @@ async function pendingRequest(data: any): Promise<any> {
}

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

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

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,13 +330,17 @@ describe('#25647 — a computed recipient address must be a user', () => {
it.each([
['names no channel (inbox, the default)', undefined],
['names inbox only', ['inbox']],
])('E5: a node that %s mails nobody, so its computed address goes to messaging as before', async (_label, channels) => {
])('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) => {
const { engine, emitted } = bootUnchecked();
engine.registerFlow('applicant_notice', notifyFlow({ recipients: [CEL('record.contact_text')], channels }));
const run = await engine.execute('applicant_notice', onCreate('lead_1', LEAD));
expect(run.success).toBe(true);
expect(emitted).toHaveLength(1);
expect(emitted[0].audience).toEqual(['stranger@outside.example']);
// No directory to check it: refused as undeliverable, not by the mail screen.
expect(run.success).toBe(false);
expect(emitted).toHaveLength(0);
const runs = await engine.listRuns('applicant_notice');
const message = runs[0]?.steps.find((s) => s.nodeId === 'notify')?.error?.message ?? '';
expect(message).toContain('Only the email channel sends to an address');
expect(message).not.toContain('A computed recipient must be a user');
});

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