Skip to content

Commit 52f3f51

Browse files
docs(spec): a notify node's recipients names a written-out email address as an author constant, not an audience selector (#25739)
Fixes #25696 Clause-②: no Metadata-protocol: semantic Ruling-ref: 6386629237 ## What this changes The `domain:spec` half of ruling B on #25647 (`6386629237`, batch #12 item 2, maintainer 「25635 25647 25675 同意」): > 「**B** = 只关「表达式算出来的地址」:`notify` 收件人若由表达式(CEL 信封)求值得到一个不属于任何用户的邮箱地址,节点失败并给出定位提示指向 `{ emailField }`;求值得到某个用户的地址仍解析为该用户。元数据里**写死**的地址是作者常量,照常发出。」 Its spec workload: 「`domain:spec` 改描述文字,把「写死地址」写成作者常量,并从受众选择器列表里去掉笼统的「an email address」。」 Text only, all in `packages/spec/src/automation/io-node-config.zod.ts`. No key, shape, default, enum or accepted value moves; everything that parsed still parses. - **The `recipients` docblock** (the `NotifyRecipientsSlotSchema` JSDoc): - "an email address" is gone from the audience-selector list, which now reads "(such as `team:ID`)". - A string address the author writes out is named an **author constant**, sent as written: to the user who holds it, or else, on the `email` channel, to the address itself. - A paragraph states what the runtime half (#25721, `d1e329e96d`, `screenComputedAddresses` in service-automation's notify node) makes. In a node whose `channels` include `email`, 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. One that no user holds, or one that cannot be looked up, fails the node, pointing to `{ emailField }`. - A node that does not name `email` mails nobody, and its computed recipients reach the messaging resolver unscreened, as before. That channel scope is confirmed (ruling record 6393736930 on #25647). - A second paragraph states the email-field delivery the same change makes: the address is read from the stored trigger row and must be one plain ASCII mailbox. One address receives at most 5 sends per hour, best-effort. - **The field `.describe()`** (what the reference docs and the JSON Schema read) and **the union's `.meta()` description** say the same. - The `.describe()` sentence "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" is replaced with what the runtime does. - **`NOTIFY_RECIPIENTS_RULE`**, the sentence a refused `recipients` value leads with, now lists "an email address written out, an author constant sent as written" beside a user id and an audience selector. A refused author is told every string spelling. Its opening, "`recipients` is who the notification goes to", is unchanged (pinned by `io-node-config.test.ts`). - **The email-field section note** no longer calls `{ emailField }` "the one recipient form that is an email ADDRESS". It is the form for an address the record supplies, and it is delivered since `d1e329e96d`. An address written out by the author is the other way to one. - Regenerated `content/docs/references/automation/io-node-config.mdx` with `gen:docs`, and added the changeset `.changeset/25696-notify-recipients-describe.md` (`@objectstack/spec`: `minor`, the level `Metadata-protocol: semantic` takes). ## The refusal is made: the runtime half landed When this PR opened, the computed-address refusal was worded as planned, because the runtime half was still open (triage `6387591836`). PR #25721 (`d1e329e96d`) has since landed the runtime half for both #25571 and #25647, and this branch merged it (`f6c07d3953`). The text now states what is made, read from the merged `packages/services/service-automation/src/builtin/notify-node.ts`: - `resolveRecipients` marks each recipient that an envelope computed and that reads as an address (`mailedAddressOf`: bare, or the remainder after `user:`). - `screenComputedAddresses` runs only when `toStringList(cfg.channels).includes('email')`. - `resolveEmailFieldAddress` reads the stored row and requires `isOneAddress`. - `admitEmailFieldSend` caps sends at `EMAIL_FIELD_SENDS_PER_WINDOW` (5) per `EMAIL_FIELD_SEND_WINDOW_SECONDS` (3600), and admits on a counter fault. - A written-out address never reaches the screen. ## What each claim in the new text rests on - *A written-out address reaches the user who holds it, or else, on the `email` channel, the address itself:* `RecipientResolver.resolveOne` → `resolveEmail` (a `sys_user` lookup by `email`, verbatim on a miss), then `email-channel.ts`'s `EMAIL_SHAPE(recipient)` branch. `screenComputedAddresses` never sees a written-out value. - *Computed address, email node:* `screenComputedAddresses`, quoted above. - *Computed address, non-email node:* the `mails` gate in `registerNotifyNode` passes `audience.value.recipients` through unscreened. - *Email-field delivery:* `resolveEmailFieldAddress`, `isOneAddress` and `admitEmailFieldSend`. ## Verification (head `83ce3e8bad`, `origin/main` `d1e329e96d` merged in) `83ce3e8bad` differs from `2e4fad24c5`, where the build and the consumer-suite readings below were taken, only in a docblock comment and the changeset. A round-2 edit to `.changeset/25610-notify-email-field-recipient.md` (`aed84ec582`) was restored byte-identical to the merge base (`29733bf86f`). Round 3 (`83ce3e8bad`) states the confirmed email-channel scope. Readings at `83ce3e8bad`: - Spec rebuilt: VERDICT 0. - `dispatch-gates --commands` for the two changed paths derives 83 commands. 82 exit 0. `check:liveness` exits 1 on the main-caused `translation/flows.screens` row. - `--ran` prints: `✓ dispatch-gates --ran: 83 derived famil(ies) accounted for — 83 run, 0 NOT-MEASURED`. - `check:generated`: its only red lane is `check:liveness`. - Spec `typecheck`: VERDICT 0. - Spec `test`: `1 failed | 720 passed (721)`, `23 failed | 21810 passed`. All 23 failures are in `check-liveness.test.ts`, the main-caused file. - Spec `test:repo`: `79 passed (79)`, `1269 passed (1269)`. - The changeset family and `check:nul-bytes` exit 0. - `pnpm install --frozen-lockfile` exit 0. Full `turbo run build --filter=!@objectstack/docs --concurrency=2`: VERDICT command-exit 0. - `check:generated` after the text change named `content/docs/references/**` stale. After `gen:docs` the reference is regenerated, and `check:docs` exits 0. - `pnpm --filter @objectstack/spec test`: `Test Files 1 failed | 720 passed (721)`, `Tests 23 failed | 21810 passed | 1 todo (21834)`. - All 23 failures are in `scripts/liveness/check-liveness.test.ts`, which reads service-automation's `engine.ts`. - #25734 (`0189524a41`, on `main`) moved that file's cited code out, and the fix is #25742. - The same suite was 721/721 green at `6cfe103516`, before `0189524a41` was merged in. - `pnpm --filter @objectstack/spec test:repo`: `79 passed (79)` files, `1269 passed (1269)` tests, VERDICT 0. - `pnpm --filter @objectstack/spec typecheck`: VERDICT 0. - Full suites of the packages that read the contract: - `@objectstack/lint`: `161 passed (161)` files, `7285 passed (7285)` tests. - `@objectstack/service-automation`: `202 passed (202)` files, `2659 passed (2659)` tests. - `@objectstack/metadata-protocol`: `263 passed | 3 skipped (266)` files, `21485 passed | 19 skipped (21504)` tests. - Each VERDICT 0. - Derived gates at `2e4fad24c5`: 107 commands, 105 exit 0, and 2 exit 1 (`check:liveness`, and `check:generated` because it runs `check:liveness`). - Both name only `translation/flows.screens → packages/services/service-automation/src/engine.ts`, the same main-caused row. - `--ran` prints: `✓ dispatch-gates --ran: 107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED (a DERIVED zero — all 107 recorded an exit code and none of them is 3).` - `affected-docs`: `0 docs name something this change touched`. - Control-byte self-scan: no hits. `check:nul-bytes` exits 0. - No ablation: no tested behaviour changes here. ## Acceptance notes - **Hand-written docs:** `content/docs/automation/flows.mdx` now carries #25721's paragraphs on the email-field form and the computed-address rule (from `:359`). They match the runtime and this text, so I changed nothing there. `:340` still lists a user id and `team:ID` and never calls a bare address a selector, so it is not false. The runtime's own texts and the migration entry `18.flow-value-slot-template-dialect-refused.ts:90` are not false either. - **Pending changeset made false by #25721, not by this PR:** the last paragraph of `.changeset/25610-notify-email-field-recipient.md` says delivery "lands separately; until it does, a run that reaches such a node fails at the node". That has been false since #25721 (`d1e329e96d`). This PR does not correct it: the release-note gate (#17712, `check-empty-changeset`) holds a correction of another PR's pending changeset red until a person confirms it, so the seat has filed it for the maintainer as a separate decision, #25754. - **Downstream producers, measured (anonymous reads):** - objectui `7981384`, `packages/app-shell/src/views/metadata-admin/inspectors/FlowRecipientsField.tsx:15`: the "Email address" row stores an author constant, unaffected. - The "Record field" row (`:12`) stores `{ dialect: 'cel', source: 'record.FIELD' }`. Over an `email`-typed field on an `email` node, that is now refused unless a user holds the address. Routing such fields to `{ emailField }` belongs to the runtime lane. - hotcrm `8a14b56`: no written-out recipient address. - `examples/app-showcase/src/automation/flows/index.ts`: `exec@example.com` (`:1435`) and `admin@objectos.ai` ×4 (`:430`, `:1454`, `:1567`, `:1730`) are author constants. No showcase `notify` node names the `email` channel. - **CI:** `Test Core (1/6)` and `Spec property liveness` are red on `main`'s `0189524a41` (`check-liveness.test.ts` / `check:liveness`), fixed by #25742 (PR #25751), which is not landed yet. They go green on the next merge of `main` once it lands. --- _Generated by [Claude Code](https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 4034f47 commit 52f3f51

3 files changed

Lines changed: 74 additions & 18 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
Metadata-protocol: semantic
9+
Ruling-ref: 6386629237
10+
11+
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.
12+
13+
- **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`.
14+
- **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).
15+
- **`{ 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.
16+
17+
No key, shape, default or accepted value changes. Everything that parsed still parses, and authored metadata needs no change.

‎content/docs/references/automation/io-node-config.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const result = HttpConfigSchema.parse(data);
9595

9696
| Property | Type | Required | Description |
9797
| :--- | :--- | :--- | :--- |
98-
| **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. |
98+
| **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. |
9999
| **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. |
100100
| **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`. |
101101
| **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. |

‎packages/spec/src/automation/io-node-config.zod.ts‎

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -248,22 +248,27 @@ function notifyValuePhrase(value: unknown): string {
248248

249249
// ─── notify email-field recipient (ruling 6377731715 on #25496, letter B) ──
250250
//
251-
// The one recipient form that is an email ADDRESS rather than a user: the
252-
// address held in an `email`-typed field of the record that triggered the
253-
// flow — for a person who is not a user, such as an applicant who came in
254-
// through a public form (ADR-0138 guest model). It NAMES the field, so a free
255-
// string or an expression can never parse as it: a string in `recipients` is
256-
// a user id or an audience selector, an envelope is a CEL value, and this arm
257-
// is a closed object whose one key holds a field's machine name. Which field
258-
// that is, and that it is `email`-typed, is judged where the flow's trigger
259-
// and the object's fields meet — `@objectstack/lint`'s
251+
// The recipient form for an email ADDRESS the RECORD supplies, rather than a
252+
// user: the address held in an `email`-typed field of the record that
253+
// triggered the flow — for a person who is not a user, such as an applicant
254+
// who came in through a public form (ADR-0138 guest model). (An address the
255+
// AUTHOR writes out as a string is the other way to one: an author constant,
256+
// sent as written — ruling 6386629237 on #25647, letter B; see the
257+
// `recipients` docblock below.) It NAMES the field, so a free string or an
258+
// expression can never parse as it: a string in `recipients` is a user id, an
259+
// audience selector or an address written out, an envelope is a CEL value,
260+
// and this arm is a closed object whose one key holds a field's machine
261+
// name. Which field that is, and that it is `email`-typed, is judged where
262+
// the flow's trigger and the object's fields meet — `@objectstack/lint`'s
260263
// `validateNotifyEmailFieldRecipients`, a reference-integrity rule, so every
261264
// authoring command and the runtime publish door refuse it.
262265
//
263266
// It is the WHOLE `recipients` value, never a list entry, and the node names
264267
// `email` as its only channel (the `superRefine` of `NotifyConfigSchema`): an
265268
// address that no user holds has no inbox, so the email channel is the one
266-
// that can deliver to it.
269+
// that can deliver to it. The runtime delivers it since #25721 (`d1e329e96d`):
270+
// service-automation's notify node reads the address off the STORED trigger
271+
// row and limits sends per address (see the `recipients` docblock below).
267272

268273
/** A field's machine name — the grammar of `FieldSchema.name`, which `emailField` names. */
269274
const FIELD_MACHINE_NAME = /^[a-z_][a-z0-9_]*$/;
@@ -321,9 +326,10 @@ function emailFieldRecipientRefusal(value: unknown): string {
321326
* recipient leads with.
322327
*/
323328
const NOTIFY_RECIPIENTS_RULE =
324-
'`recipients` is who the notification goes to: a recipient — a user id, or an audience selector such as '
325-
+ '`team:ID` — written as a string; a list of them, each a string or a CEL value envelope; one CEL value '
326-
+ 'envelope, `{ dialect: \'cel\', source: \'…\' }`, that evaluates to a recipient or a list of them; or an '
329+
'`recipients` is who the notification goes to: a recipient — a user id, an audience selector such as '
330+
+ '`team:ID`, or an email address written out, an author constant sent as written — as a string; a list of '
331+
+ 'them, each a string or a CEL value envelope; one CEL value envelope, `{ dialect: \'cel\', source: \'…\' }`, '
332+
+ 'that evaluates to a recipient or a list of them; or an '
327333
+ 'email-field recipient, `{ emailField: \'applicant_email\' }`, the address in an `email`-typed field of the '
328334
+ 'record that triggered the flow.';
329335

@@ -352,7 +358,11 @@ function notifyRecipientsRefusal(input: unknown): string {
352358
* one value OR a list of them (#19939; the ledger's `eachElement`):
353359
*
354360
* - a **string** is one recipient as written — a user id, or an audience
355-
* selector the messaging service resolves (`team:ID`, an email address);
361+
* selector the messaging service resolves (such as `team:ID`); an email
362+
* address written out as a string is no audience selector but an **author
363+
* constant**, sent as written: to the user who holds it, or else, on the
364+
* `email` channel, to the address itself (ruling 6386629237 on #25647,
365+
* letter B);
356366
* - a **list** holds one entry per recipient, each a string as written or a
357367
* CEL value envelope evaluated per run — an entry that evaluates to a list
358368
* (a multi-user field, a list variable) adds EACH of its recipients, so a
@@ -363,6 +373,27 @@ function notifyRecipientsRefusal(input: unknown): string {
363373
* the whole value only, names an `email`-typed field of the triggering
364374
* record whose address is the recipient.
365375
*
376+
* An address a CEL value envelope EVALUATES to is not an author constant (the
377+
* same ruling, made by #25721, `d1e329e96d`: `screenComputedAddresses` in
378+
* service-automation's notify node). In a node whose `channels` include
379+
* `email` — the one channel that sends to the address itself — a computed
380+
* recipient that reads as an address, bare or as `user:ADDRESS`, must be a
381+
* user's: one a user holds resolves to that user's id, and one that NO user
382+
* holds fails the node with a located message pointing to `{ emailField }`,
383+
* as does one that cannot be looked up (no data engine, a failing lookup). A
384+
* node that does not name `email` (`inbox` alone, the messaging default)
385+
* mails nobody, and its computed recipients reach the messaging resolver
386+
* unscreened, as before. That channel scope is the confirmed reading of the
387+
* ruling (ruling record 6393736930 on #25647: the maintainer chose to screen
388+
* only nodes that include the `email` channel).
389+
*
390+
* The same change delivers an email-field recipient: the address is read from
391+
* the STORED trigger row (never the run's `record` snapshot), must be one
392+
* plain ASCII mailbox, and one address receives at most 5 such sends per hour
393+
* across every flow — best-effort: a faulting `cache` counter admits the send
394+
* and says so once at `warn`. Each way there is no address fails the node and
395+
* sends nothing.
396+
*
366397
* An envelope that evaluates to `null` or a blank string adds no recipient,
367398
* as a token that resolved to nothing did, and the executor's guard still
368399
* fails a node left with none, naming the sources that evaluated to nothing
@@ -392,7 +423,9 @@ const NotifyRecipientsSlotSchema = z.union([
392423
+ 'string; a list of them, each a string or a CEL value envelope `{ dialect: \'cel\', source }` evaluated per '
393424
+ 'run (an entry that evaluates to a list adds each of its recipients); one envelope evaluating to a '
394425
+ 'recipient or a list; or `{ emailField }`, the address in an `email`-typed field of the triggering record '
395-
+ '— a `{…}` template token is refused',
426+
+ '— a `{…}` template token is refused. An email address written out as a string is an author constant, '
427+
+ 'sent as written; in a node whose `channels` include `email`, one an envelope evaluates to must be a '
428+
+ 'user\'s: it resolves to that user, and one no user holds fails the node, pointing to `{ emailField }`',
396429
xExpression: 'value',
397430
});
398431

@@ -635,8 +668,14 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({
635668
+ 'none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the '
636669
+ 'whole value, `{ emailField: \'applicant_email\' }`: the address in that `email`-typed field of the record '
637670
+ 'that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. '
638-
+ 'Delivery to an email-field recipient is not live yet: until the messaging service resolves the form, a '
639-
+ 'run that reaches the node fails there.',
671+
+ 'The address is read from the stored record when the node runs and must be one plain ASCII mailbox, or the '
672+
+ 'node fails and nothing is sent; one address receives at most 5 such emails per hour, best-effort. An email '
673+
+ 'address written out as a string is no audience selector but an author constant, sent as written: to the '
674+
+ 'user who holds it, or else, on the `email` channel, to the address itself. An address an envelope '
675+
+ 'evaluates to is not one: in a node whose `channels` include `email`, it must be a user\'s, written bare or '
676+
+ 'as `user:ADDRESS` — one a user holds resolves to that user, and one that no user holds fails the node, '
677+
+ 'pointing to `{ emailField }`. A node that does not name `email` mails nobody, and its computed recipients '
678+
+ 'go to the messaging service as before.',
640679
),
641680
/**
642681
* Inline notification title — the NON-localizable content path, and a

0 commit comments

Comments
 (0)