From 493a7d32fe11a9476b90aa0b2ffec6134f4f91da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:38:39 +0000 Subject: [PATCH 1/7] docs(spec): notify recipients name a written-out address as an author constant The `recipients` docblock no longer lists "an email address" among the audience selectors. A string address the author writes out is named an author constant, sent as written; an address a CEL envelope evaluates to resolves to the user who holds it, and failing the node on one that no user holds, pointing to `{ emailField }`, is stated as planned: the runtime half has not landed. Text only: no key, shape or default moves. Claude-Session: https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT Co-authored-by: Claude --- .../spec/src/automation/io-node-config.zod.ts | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index f0cdbbfe67a..a058676a24f 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -248,15 +248,18 @@ function notifyValuePhrase(value: unknown): string { // ─── notify email-field recipient (ruling 6377731715 on #25496, letter B) ── // -// The one recipient form that is an email ADDRESS rather than a user: the -// address held in an `email`-typed field of the record that triggered the -// flow — for a person who is not a user, such as an applicant who came in -// through a public form (ADR-0138 guest model). It NAMES the field, so a free -// string or an expression can never parse as it: a string in `recipients` is -// a user id or an audience selector, an envelope is a CEL value, and this arm -// is a closed object whose one key holds a field's machine name. Which field -// that is, and that it is `email`-typed, is judged where the flow's trigger -// and the object's fields meet — `@objectstack/lint`'s +// The recipient form for an email ADDRESS the RECORD supplies, rather than a +// user: the address held in an `email`-typed field of the record that +// triggered the flow — for a person who is not a user, such as an applicant +// who came in through a public form (ADR-0138 guest model). (An address the +// AUTHOR writes out as a string is the other way to one: an author constant, +// sent as written — ruling 6386629237 on #25647, letter B; see the +// `recipients` docblock below.) It NAMES the field, so a free string or an +// expression can never parse as it: a string in `recipients` is a user id, an +// audience selector or an address written out, an envelope is a CEL value, +// and this arm is a closed object whose one key holds a field's machine +// name. Which field that is, and that it is `email`-typed, is judged where +// the flow's trigger and the object's fields meet — `@objectstack/lint`'s // `validateNotifyEmailFieldRecipients`, a reference-integrity rule, so every // authoring command and the runtime publish door refuse it. // @@ -321,9 +324,10 @@ function emailFieldRecipientRefusal(value: unknown): string { * recipient leads with. */ const NOTIFY_RECIPIENTS_RULE = - '`recipients` is who the notification goes to: a recipient — a user id, or an audience selector such as ' - + '`team:ID` — written as a string; a list of them, each a string or a CEL value envelope; one CEL value ' - + 'envelope, `{ dialect: \'cel\', source: \'…\' }`, that evaluates to a recipient or a list of them; or an ' + '`recipients` is who the notification goes to: a recipient — a user id, an audience selector such as ' + + '`team:ID`, or an email address written out, an author constant sent as written — as a string; a list of ' + + 'them, each a string or a CEL value envelope; one CEL value envelope, `{ dialect: \'cel\', source: \'…\' }`, ' + + 'that evaluates to a recipient or a list of them; or an ' + 'email-field recipient, `{ emailField: \'applicant_email\' }`, the address in an `email`-typed field of the ' + 'record that triggered the flow.'; @@ -352,7 +356,11 @@ function notifyRecipientsRefusal(input: unknown): string { * one value OR a list of them (#19939; the ledger's `eachElement`): * * - a **string** is one recipient as written — a user id, or an audience - * selector the messaging service resolves (`team:ID`, an email address); + * selector the messaging service resolves (such as `team:ID`); an email + * address written out as a string is no audience selector but an **author + * constant**, sent as written: to the user who holds it, or else, on the + * `email` channel, to the address itself (ruling 6386629237 on #25647, + * letter B); * - a **list** holds one entry per recipient, each a string as written or a * CEL value envelope evaluated per run — an entry that evaluates to a list * (a multi-user field, a list variable) adds EACH of its recipients, so a @@ -363,6 +371,15 @@ function notifyRecipientsRefusal(input: unknown): string { * the whole value only, names an `email`-typed field of the triggering * record whose address is the recipient. * + * An address a CEL value envelope EVALUATES to is not an author constant: one + * a user holds resolves to that user, and one that NO user holds is to fail + * the node with a located message pointing to `{ emailField }` (the same + * ruling). That refusal is PLANNED, not made: the runtime half (#25647, which + * may ride #25571's files) has not landed, so until it does such an address is + * still handed to the messaging service as written. The describes below say + * "planned" for that reason — ⛔ they claim no refusal the runtime does not + * make yet; when the runtime half lands, it rewrites them as made. + * * An envelope that evaluates to `null` or a blank string adds no recipient, * as a token that resolved to nothing did, and the executor's guard still * fails a node left with none, naming the sources that evaluated to nothing @@ -392,7 +409,9 @@ const NotifyRecipientsSlotSchema = z.union([ + 'string; a list of them, each 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); one envelope evaluating to a ' + 'recipient or a list; or `{ emailField }`, the address in an `email`-typed field of the triggering record ' - + '— a `{…}` template token is refused', + + '— a `{…}` template token is refused. An email address written out as a string is an author constant, ' + + 'sent as written; one an envelope evaluates to resolves to the user who holds it, and failing the node on ' + + 'one no user holds, pointing to `{ emailField }`, is planned and not made yet', xExpression: 'value', }); @@ -636,7 +655,11 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ + 'whole value, `{ emailField: \'applicant_email\' }`: the address in that `email`-typed field of the record ' + 'that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. ' + 'Delivery to an email-field recipient is not live yet: until the messaging service resolves the form, a ' - + 'run that reaches the node fails there.', + + 'run that reaches the node fails there. An email address written out as a string is no audience selector ' + + 'but an author constant, sent as written: to the user who holds it, or else, on the `email` channel, to ' + + 'the address itself. An address an envelope evaluates to is not one: it resolves to the user who holds it, ' + + 'and one that no user holds is planned to fail the node, pointing to `{ emailField }` — that refusal is not ' + + 'made yet, so until it is, such an address is still handed to the messaging service as written.', ), /** * Inline notification title — the NON-localizable content path, and a From c41e582098799f185813d03c871ecfb1c77b32cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:42:53 +0000 Subject: [PATCH 2/7] docs(spec): regenerate the automation io-node-config reference; add the changeset Claude-Session: https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT Co-authored-by: Claude --- .changeset/25696-notify-recipients-describe.md | 16 ++++++++++++++++ .../references/automation/io-node-config.mdx | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/25696-notify-recipients-describe.md diff --git a/.changeset/25696-notify-recipients-describe.md b/.changeset/25696-notify-recipients-describe.md new file mode 100644 index 00000000000..fe5eeaee24e --- /dev/null +++ b/.changeset/25696-notify-recipients-describe.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +--- + +docs(spec): a `notify` node's `recipients` names an email address written out as an author constant, not an audience selector + +Clause-②: no +Metadata-protocol: semantic +Ruling-ref: 6386629237 + +The `recipients` text of a `notify` node config — its `describe()` (read by the reference docs and the JSON Schema), the union's description, and the sentence a refused `recipients` value leads with — now says what an email address in `recipients` means: + +- **Written out as a string, it is an author constant.** `recipients: 'ops@example.com'` is no audience selector: it is sent as written, to the user who holds that address, or else, on the `email` channel, to the address itself. Nothing about it changes; the text stopped calling it an audience selector. The audience selectors are the prefixed forms, such as `team:ID`. +- **An address a CEL envelope evaluates to is not one.** It resolves to the user who holds it. Failing the node on an address that no user holds, with a message pointing to `{ emailField }`, is **planned and not made yet**: until the runtime check lands, such an address is still handed to the messaging service as written, and the text says so rather than claim a refusal the runtime does not make. + +No key, shape, default or accepted value changes; everything that parsed still parses, and authored metadata needs no change. diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index e877c00afb5..4bf28aa7177 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -95,7 +95,7 @@ const result = HttpConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **recipients** | `string \| (string \| { dialect: 'cel'; source: string; ast?: any; meta?: object })[] \| { dialect: 'cel'; source: string; ast?: any; meta?: object } \| { emailField: string }` | ✅ | Who the notification goes to: a recipient (a user id, or an audience selector such as `team:ID`) as a string; a list of them, each 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 one that evaluates to null adds none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the whole value, `{ emailField: 'applicant_email' }`: the address in that `email`-typed field of the record that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. Delivery to an email-field recipient is not live yet: until the messaging service resolves the form, a run that reaches the node fails there. | +| **recipients** | `string \| (string \| { dialect: 'cel'; source: string; ast?: any; meta?: object })[] \| { dialect: 'cel'; source: string; ast?: any; meta?: object } \| { emailField: string }` | ✅ | Who the notification goes to: a recipient (a user id, or an audience selector such as `team:ID`) as a string; a list of them, each 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 one that evaluates to null adds none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the whole value, `{ emailField: 'applicant_email' }`: the address in that `email`-typed field of the record that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. Delivery to an email-field recipient is not live yet: until the messaging service resolves the form, a run that reaches the node fails there. An email address written out as a string is no audience selector but an author constant, sent as written: to the user who holds it, or else, on the `email` channel, to the address itself. An address an envelope evaluates to is not one: it resolves to the user who holds it, and one that no user holds is planned to fail the node, pointing to `{ emailField }` — that refusal is not made yet, so until it is, such an address is still handed to the messaging service as written. | | **title** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification title — a template: a bare string, or a `{ dialect: 'template', source }` envelope (the `tmpl` helper) carrying the same text. It is rendered per run with `{{ }}` holes over the flow's variables — a variable path with an optional formatter (`{{ record.name }}`, `{{ record.amount \| currency }}`); a single-brace `{token}` is refused. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. | | **message** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification body — the same template input as `title` (a bare string or a `{ dialect: 'template', source }` envelope), rendered per run with `{{ }}` holes like `title`; not localizable. Only valid with inline `title`, never with `template`. | | **template** | `string` | optional | Email template name (an `email_template` item's `name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` among the email templates — built-in, declared by an app, or authored in Studio — at delivery time and renders subject/body from that member. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different members of the same bundle. The node's `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | From 1a41859ca9d190bc27bd97112a5111ca4e8d7505 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:47:54 +0000 Subject: [PATCH 3/7] docs(spec): notify recipients state the computed-address screen and email-field delivery the runtime now makes The runtime half landed in d1e329e96d. A computed recipient that reads as an address, in a node whose channels include `email`, now resolves to the user who holds it or fails the node pointing to `{ emailField }`. An email-field recipient is mailed, read from the stored trigger row, one plain ASCII mailbox, at most 5 sends per address per hour, best-effort. The describes, the union description, the docblock and the changeset now say so, in place of the "planned" and "not live yet" sentences. Claude-Session: https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT Co-authored-by: Claude --- .../25696-notify-recipients-describe.md | 11 ++-- .../spec/src/automation/io-node-config.zod.ts | 50 ++++++++++++------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/.changeset/25696-notify-recipients-describe.md b/.changeset/25696-notify-recipients-describe.md index fe5eeaee24e..8549847c954 100644 --- a/.changeset/25696-notify-recipients-describe.md +++ b/.changeset/25696-notify-recipients-describe.md @@ -2,15 +2,16 @@ '@objectstack/spec': minor --- -docs(spec): a `notify` node's `recipients` names an email address written out as an author constant, not an audience selector +docs(spec): a `notify` node's `recipients` names an email address written out as an author constant, not an audience selector, and states the computed-address and email-field behaviour the runtime makes Clause-②: no Metadata-protocol: semantic Ruling-ref: 6386629237 -The `recipients` text of a `notify` node config — its `describe()` (read by the reference docs and the JSON Schema), the union's description, and the sentence a refused `recipients` value leads with — now says what an email address in `recipients` means: +The `recipients` text of a `notify` node config now says what an email address in `recipients` means, and what the notify node does with one. That text is the field's `describe()` (which the reference docs and the JSON Schema read), the union's description, and the sentence a refused `recipients` value leads with. -- **Written out as a string, it is an author constant.** `recipients: 'ops@example.com'` is no audience selector: it is sent as written, to the user who holds that address, or else, on the `email` channel, to the address itself. Nothing about it changes; the text stopped calling it an audience selector. The audience selectors are the prefixed forms, such as `team:ID`. -- **An address a CEL envelope evaluates to is not one.** It resolves to the user who holds it. Failing the node on an address that no user holds, with a message pointing to `{ emailField }`, is **planned and not made yet**: until the runtime check lands, such an address is still handed to the messaging service as written, and the text says so rather than claim a refusal the runtime does not make. +- **Written out as a string, it is an author constant.** `recipients: 'ops@example.com'` is no audience selector. It is sent as written: to the user who holds that address, or else, on the `email` channel, to the address itself. Nothing about it changes; the text no longer calls it an audience selector. The audience selectors are the prefixed forms, such as `team:ID`. +- **An address a CEL envelope evaluates to is not one.** In a node whose `channels` include `email`, a computed recipient that reads as an address, bare or as `user:ADDRESS`, must belong to a user. One a user holds resolves to that user. One that no user holds fails the node, and the message points to `{ emailField }`. A node that does not name `email` (`inbox` alone, the default) mails nobody, so its computed recipients go to the messaging service as before. This is the current behaviour of the notify node; the channel scope is with the maintainer for confirmation. +- **`{ emailField }` is delivered.** The text no longer says delivery is not live. The address is read from the stored trigger record when the node runs. It must be one plain ASCII mailbox, or the node fails and nothing is sent. One address receives at most 5 such emails per hour; the limit is best-effort. -No key, shape, default or accepted value changes; everything that parsed still parses, and authored metadata needs no change. +No key, shape, default or accepted value changes. Everything that parsed still parses, and authored metadata needs no change. diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index a058676a24f..c435dad8c05 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -266,7 +266,9 @@ function notifyValuePhrase(value: unknown): string { // It is the WHOLE `recipients` value, never a list entry, and the node names // `email` as its only channel (the `superRefine` of `NotifyConfigSchema`): an // address that no user holds has no inbox, so the email channel is the one -// that can deliver to it. +// that can deliver to it. The runtime delivers it since #25721 (`d1e329e96d`): +// service-automation's notify node reads the address off the STORED trigger +// row and limits sends per address (see the `recipients` docblock below). /** A field's machine name — the grammar of `FieldSchema.name`, which `emailField` names. */ const FIELD_MACHINE_NAME = /^[a-z_][a-z0-9_]*$/; @@ -371,14 +373,26 @@ function notifyRecipientsRefusal(input: unknown): string { * the whole value only, names an `email`-typed field of the triggering * record whose address is the recipient. * - * An address a CEL value envelope EVALUATES to is not an author constant: one - * a user holds resolves to that user, and one that NO user holds is to fail - * the node with a located message pointing to `{ emailField }` (the same - * ruling). That refusal is PLANNED, not made: the runtime half (#25647, which - * may ride #25571's files) has not landed, so until it does such an address is - * still handed to the messaging service as written. The describes below say - * "planned" for that reason — ⛔ they claim no refusal the runtime does not - * make yet; when the runtime half lands, it rewrites them as made. + * An address a CEL value envelope EVALUATES to is not an author constant (the + * same ruling, made by #25721, `d1e329e96d`: `screenComputedAddresses` in + * service-automation's notify node). In a node whose `channels` include + * `email` — the one channel that sends to the address itself — a computed + * recipient that reads as an address, bare or as `user:ADDRESS`, must be a + * user's: one a user holds resolves to that user's id, and one that NO user + * holds fails the node with a located message pointing to `{ emailField }`, + * as does one that cannot be looked up (no data engine, a failing lookup). A + * node that does not name `email` (`inbox` alone, the messaging default) + * mails nobody, and its computed recipients reach the messaging resolver + * unscreened, as before. That channel scope is the runtime's current reading + * of the ruling and is with the maintainer for confirmation, so the describes + * below state it as the current behaviour, not as final. + * + * The same change delivers an email-field recipient: the address is read from + * the STORED trigger row (never the run's `record` snapshot), must be one + * plain ASCII mailbox, and one address receives at most 5 such sends per hour + * across every flow — best-effort: a faulting `cache` counter admits the send + * and says so once at `warn`. Each way there is no address fails the node and + * sends nothing. * * An envelope that evaluates to `null` or a blank string adds no recipient, * as a token that resolved to nothing did, and the executor's guard still @@ -410,8 +424,8 @@ const NotifyRecipientsSlotSchema = z.union([ + 'run (an entry that evaluates to a list adds each of its recipients); one envelope evaluating to a ' + 'recipient or a list; or `{ emailField }`, the address in an `email`-typed field of the triggering record ' + '— a `{…}` template token is refused. An email address written out as a string is an author constant, ' - + 'sent as written; one an envelope evaluates to resolves to the user who holds it, and failing the node on ' - + 'one no user holds, pointing to `{ emailField }`, is planned and not made yet', + + 'sent as written; in a node whose `channels` include `email`, one an envelope evaluates to must be a ' + + 'user\'s: it resolves to that user, and one no user holds fails the node, pointing to `{ emailField }`', xExpression: 'value', }); @@ -654,12 +668,14 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ + 'none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the ' + 'whole value, `{ emailField: \'applicant_email\' }`: the address in that `email`-typed field of the record ' + 'that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. ' - + 'Delivery to an email-field recipient is not live yet: until the messaging service resolves the form, a ' - + 'run that reaches the node fails there. An email address written out as a string is no audience selector ' - + 'but an author constant, sent as written: to the user who holds it, or else, on the `email` channel, to ' - + 'the address itself. An address an envelope evaluates to is not one: it resolves to the user who holds it, ' - + 'and one that no user holds is planned to fail the node, pointing to `{ emailField }` — that refusal is not ' - + 'made yet, so until it is, such an address is still handed to the messaging service as written.', + + 'The address is read from the stored record when the node runs and must be one plain ASCII mailbox, or the ' + + 'node fails and nothing is sent; one address receives at most 5 such emails per hour, best-effort. An email ' + + 'address written out as a string is no audience selector but an author constant, sent as written: to the ' + + 'user who holds it, or else, on the `email` channel, to the address itself. An address an envelope ' + + 'evaluates to is not one: in a node whose `channels` include `email`, it must be a user\'s, written bare or ' + + 'as `user:ADDRESS` — one a user holds resolves to that user, and one that no user holds fails the node, ' + + 'pointing to `{ emailField }`. A node that does not name `email` mails nobody, and its computed recipients ' + + 'go to the messaging service as before.', ), /** * Inline notification title — the NON-localizable content path, and a From 2e4fad24c5c8449a7858fd8e7026141174eea8a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:58:41 +0000 Subject: [PATCH 4/7] docs(spec): regenerate the automation io-node-config reference for the made recipients text Claude-Session: https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT Co-authored-by: Claude --- content/docs/references/automation/io-node-config.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index 4bf28aa7177..55d4c0ddccf 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -95,7 +95,7 @@ const result = HttpConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **recipients** | `string \| (string \| { dialect: 'cel'; source: string; ast?: any; meta?: object })[] \| { dialect: 'cel'; source: string; ast?: any; meta?: object } \| { emailField: string }` | ✅ | Who the notification goes to: a recipient (a user id, or an audience selector such as `team:ID`) as a string; a list of them, each 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 one that evaluates to null adds none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the whole value, `{ emailField: 'applicant_email' }`: the address in that `email`-typed field of the record that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. Delivery to an email-field recipient is not live yet: until the messaging service resolves the form, a run that reaches the node fails there. An email address written out as a string is no audience selector but an author constant, sent as written: to the user who holds it, or else, on the `email` channel, to the address itself. An address an envelope evaluates to is not one: it resolves to the user who holds it, and one that no user holds is planned to fail the node, pointing to `{ emailField }` — that refusal is not made yet, so until it is, such an address is still handed to the messaging service as written. | +| **recipients** | `string \| (string \| { dialect: 'cel'; source: string; ast?: any; meta?: object })[] \| { dialect: 'cel'; source: string; ast?: any; meta?: object } \| { emailField: string }` | ✅ | Who the notification goes to: a recipient (a user id, or an audience selector such as `team:ID`) as a string; a list of them, each 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 one that evaluates to null adds none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the whole value, `{ emailField: 'applicant_email' }`: the address in that `email`-typed field of the record that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. The address is read from the stored record when the node runs and must be one plain ASCII mailbox, or the node fails and nothing is sent; one address receives at most 5 such emails per hour, best-effort. An email address written out as a string is no audience selector but an author constant, sent as written: to the user who holds it, or else, on the `email` channel, to the address itself. An address an envelope evaluates to is not one: in a node whose `channels` include `email`, it must be a user's, written bare or as `user:ADDRESS` — one a user holds resolves to that user, and one that no user holds fails the node, pointing to `{ emailField }`. A node that does not name `email` mails nobody, and its computed recipients go to the messaging service as before. | | **title** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification title — a template: a bare string, or a `{ dialect: 'template', source }` envelope (the `tmpl` helper) carrying the same text. It is rendered per run with `{{ }}` holes over the flow's variables — a variable path with an optional formatter (`{{ record.name }}`, `{{ record.amount \| currency }}`); a single-brace `{token}` is refused. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. | | **message** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification body — the same template input as `title` (a bare string or a `{ dialect: 'template', source }` envelope), rendered per run with `{{ }}` holes like `title`; not localizable. Only valid with inline `title`, never with `template`. | | **template** | `string` | optional | Email template name (an `email_template` item's `name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` among the email templates — built-in, declared by an app, or authored in Studio — at delivery time and renders subject/body from that member. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different members of the same bundle. The node's `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | From aed84ec582c48704ce2cfb768c769eb8a72df415 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:36:16 +0000 Subject: [PATCH 5/7] docs(changeset): the email-field recipient changeset says delivery ships in the same release Its last paragraph said delivery lands separately and that a run reaching such a node fails there. The runtime half landed in d1e329e96d and its own changeset is pending beside this one, so the paragraph now says delivery ships in this same release in service-automation's notify node. Claude-Session: https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT Co-authored-by: Claude --- .changeset/25610-notify-email-field-recipient.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25610-notify-email-field-recipient.md b/.changeset/25610-notify-email-field-recipient.md index 2e8f4685d2a..1ef7acd1508 100644 --- a/.changeset/25610-notify-email-field-recipient.md +++ b/.changeset/25610-notify-email-field-recipient.md @@ -22,7 +22,7 @@ A `notify` node's `recipients` takes a fourth form, as the whole value: `{ email - The form is the whole `recipients` value, never a list entry. - `@objectstack/lint` refuses, at `os validate`, `os lint`, `os build` and the runtime publish door for a flow, an email-field recipient whose field the trigger object does not declare (`notify-email-field-unknown`), one whose field is not `email`-typed (`notify-email-field-not-email`), and one in a flow that hands its run no record of a known object — a schedule, an inbound api call, a record trigger that names no object (`notify-email-field-no-trigger-record`). A trigger object another package declares is not judged. -This change is the protocol half. Delivery — resolving the field to an address, the outbound mail configuration, the per-address rate limit and the messaging-log entry — is the messaging service's half and lands separately; until it does, a run that reaches such a node fails at the node. +This change is the protocol half. Delivery — resolving the field to an address, the outbound mail configuration, the per-address rate limit and the messaging-log entry — is the runtime half, and it ships in this same release in the `notify` node of `@objectstack/service-automation`. Clause-②: yes (widening) From 29733bf86f5986b952c0db9cdda91c1edc6cb8de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:42:12 +0000 Subject: [PATCH 6/7] docs(changeset): restore the email-field recipient changeset to its merge-base text A correction to another lane's pending release note is refused by the release-note gate until a person confirms it, which would hold this PR's landing on a release decision that is not this PR's. The stale paragraph is routed to the maintainer as a separate decision instead. Claude-Session: https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT Co-authored-by: Claude --- .changeset/25610-notify-email-field-recipient.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25610-notify-email-field-recipient.md b/.changeset/25610-notify-email-field-recipient.md index 1ef7acd1508..2e8f4685d2a 100644 --- a/.changeset/25610-notify-email-field-recipient.md +++ b/.changeset/25610-notify-email-field-recipient.md @@ -22,7 +22,7 @@ A `notify` node's `recipients` takes a fourth form, as the whole value: `{ email - The form is the whole `recipients` value, never a list entry. - `@objectstack/lint` refuses, at `os validate`, `os lint`, `os build` and the runtime publish door for a flow, an email-field recipient whose field the trigger object does not declare (`notify-email-field-unknown`), one whose field is not `email`-typed (`notify-email-field-not-email`), and one in a flow that hands its run no record of a known object — a schedule, an inbound api call, a record trigger that names no object (`notify-email-field-no-trigger-record`). A trigger object another package declares is not judged. -This change is the protocol half. Delivery — resolving the field to an address, the outbound mail configuration, the per-address rate limit and the messaging-log entry — is the runtime half, and it ships in this same release in the `notify` node of `@objectstack/service-automation`. +This change is the protocol half. Delivery — resolving the field to an address, the outbound mail configuration, the per-address rate limit and the messaging-log entry — is the messaging service's half and lands separately; until it does, a run that reaches such a node fails at the node. Clause-②: yes (widening) From 83ce3e8bad176db06fa92b71e34f6cbca50ab5af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:51:49 +0000 Subject: [PATCH 7/7] docs(spec): the computed-address screen's email-channel scope is confirmed The maintainer confirmed that ruling B's screen of a computed address applies only to a notify node whose channels include email (ruling record 6393736930). The recipients docblock and the changeset no longer call the scope unconfirmed. Claude-Session: https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT Co-authored-by: Claude --- .changeset/25696-notify-recipients-describe.md | 2 +- packages/spec/src/automation/io-node-config.zod.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/25696-notify-recipients-describe.md b/.changeset/25696-notify-recipients-describe.md index 8549847c954..3e6ce36e3df 100644 --- a/.changeset/25696-notify-recipients-describe.md +++ b/.changeset/25696-notify-recipients-describe.md @@ -11,7 +11,7 @@ Ruling-ref: 6386629237 The `recipients` text of a `notify` node config now says what an email address in `recipients` means, and what the notify node does with one. That text is the field's `describe()` (which the reference docs and the JSON Schema read), the union's description, and the sentence a refused `recipients` value leads with. - **Written out as a string, it is an author constant.** `recipients: 'ops@example.com'` is no audience selector. It is sent as written: to the user who holds that address, or else, on the `email` channel, to the address itself. Nothing about it changes; the text no longer calls it an audience selector. The audience selectors are the prefixed forms, such as `team:ID`. -- **An address a CEL envelope evaluates to is not one.** In a node whose `channels` include `email`, a computed recipient that reads as an address, bare or as `user:ADDRESS`, must belong to a user. One a user holds resolves to that user. One that no user holds fails the node, and the message points to `{ emailField }`. A node that does not name `email` (`inbox` alone, the default) mails nobody, so its computed recipients go to the messaging service as before. This is the current behaviour of the notify node; the channel scope is with the maintainer for confirmation. +- **An address a CEL envelope evaluates to is not one.** In a node whose `channels` include `email`, a computed recipient that reads as an address, bare or as `user:ADDRESS`, must belong to a user. One a user holds resolves to that user. One that no user holds fails the node, and the message points to `{ emailField }`. A node that does not name `email` (`inbox` alone, the default) mails nobody, so its computed recipients go to the messaging service as before. The screen is scoped to nodes that include the `email` channel (confirmed by the maintainer). - **`{ emailField }` is delivered.** The text no longer says delivery is not live. The address is read from the stored trigger record when the node runs. It must be one plain ASCII mailbox, or the node fails and nothing is sent. One address receives at most 5 such emails per hour; the limit is best-effort. No key, shape, default or accepted value changes. Everything that parsed still parses, and authored metadata needs no change. diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index c435dad8c05..65a51335550 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -383,9 +383,9 @@ function notifyRecipientsRefusal(input: unknown): string { * as does one that cannot be looked up (no data engine, a failing lookup). A * node that does not name `email` (`inbox` alone, the messaging default) * mails nobody, and its computed recipients reach the messaging resolver - * unscreened, as before. That channel scope is the runtime's current reading - * of the ruling and is with the maintainer for confirmation, so the describes - * below state it as the current behaviour, not as final. + * unscreened, as before. That channel scope is the confirmed reading of the + * ruling (ruling record 6393736930 on #25647: the maintainer chose to screen + * only nodes that include the `email` channel). * * The same change delivers an email-field recipient: the address is read from * the STORED trigger row (never the run's `record` snapshot), must be one