Skip to content

Commit 6021b5e

Browse files
feat(spec,lint): a notify recipient form { emailField } — the address in an email-typed field of the triggering record (#25645)
Fixes #25610 Clause-②: yes (widening) Metadata-protocol: added Ruling-ref: 6377731715 The protocol half of #25571. Ruling 6377731715 on #25496 (batch #388 item 4, letter B for #25444), agreeing with 6377842908 on this row: 「收件人只能来自记录的 email 类型字段,⛔ 不接受自由文本或表达式拼出的地址;按地址限速,记消息日志」. The runtime half (resolving the field to an address, template render, outbound mail configuration, per-address rate limit, guest-input sanitising, the messaging-log entry) is #25571 and is not in this PR. ## What changes **`@objectstack/spec` — the form.** `notify.recipients` takes a fourth union arm, `NotifyEmailFieldRecipientSchema` (exported, with its type `NotifyEmailFieldRecipient`): `{ emailField: 'applicant_email' }`, a closed object whose one key holds a field's machine name (`FieldSchema.name` grammar). A string can never parse as it (a string stays a user id or audience selector), and an envelope can never parse as it (an envelope stays a CEL value; `emailField` holding an envelope is refused). Two more constraints ride `NotifyConfigSchema`: - it is the WHOLE `recipients` value, never a list entry (refused with its own sentence); - the node names `email` as its only channel (`channels: 'email'` or `['email']`); no `channels`, or any other channel, is refused at `channels`. An address no user holds has no inbox. **`@objectstack/lint` — the publish-time refusal.** `validateNotifyEmailFieldRecipients`, a reference-integrity suite member (so `os validate`, `os lint`, `os build` and the runtime publish door for a `flow` write all run it), resolves the named field against the trigger object's declared fields. Three gating verdicts: | rule id | when | |---|---| | `notify-email-field-unknown` | the trigger object declares no such field | | `notify-email-field-not-email` | the field is declared and is not `email`-typed | | `notify-email-field-no-trigger-record` | the flow hands its run no record of a known object: a schedule, an inbound api call, a record trigger naming no object, or no record trigger at all | The triggering record's object is the start node's `objectName` for a record trigger and `timeRelative.object` for a time-relative sweep. A trigger object this stack does not declare (another package's, `sys_*`) or one declaring no fields is skipped, like the rest of the field-existence family. ## Measured premise (origin/main 481ac62) - `NotifyRecipientsSlotSchema` (was `:292`) types `recipients` (was `:547`) as string | list | CEL envelope. Holds. - PM assumption "a liveness-ledger row in `packages/spec/liveness/flow.json`": **disproved.** `FlowNodeSchema.config` is `z.record(z.string(), z.unknown())` (`flow.zod.ts:630`), and `flow.json` holds `nodes.config` as a single `live` leaf with no children. No ledger row can name a notify config key, let alone one union arm of its value. So no ledger row was added and the gate was not touched. Instead, the `recipients` `.describe()` (what authors read in Studio and the reference docs) and the changeset both say delivery is not live until the messaging half lands. - What a run does with the form today (read, not executed): `notify-node.ts#resolveRecipients` hands a non-envelope object through `resolveValueSlot` unchanged and fails the node with "evaluated to a map, not a recipient". So the form fails at the node, loudly. It is never silently dropped. ## Tests The full package suites ran on `8934b74b`, before the one `origin/main` merge (`8040d181`, which brought #25617). On the final head `433f7f2005` (merge plus the ADR-0122 `NotifyEmailFieldRecipientParsed` alias) I re-ran `tsc --noEmit` for spec, the four touched test files (139 tests), `check:generated` (all 14 up to date) and every derived gate (below). - `pnpm --filter @objectstack/spec test`: 715 files / 21704 tests passed (1 todo). Includes the new `notify-email-field-recipient.test.ts` (17 tests) and the updated JSON-Schema arm pin in `io-node-config.test.ts`. - `pnpm --filter @objectstack/lint test`: 159 files / 7243 passed, 5 skipped. Includes the new `validate-notify-email-field-recipients.test.ts` (11 tests) and the suite's member list and every-member-fires pins. - `pnpm --filter @objectstack/spec typecheck`, `pnpm --filter @objectstack/lint typecheck`: green. - Consumer: `pnpm --filter @objectstack/service-automation typecheck` and `test` (199 files / 2628 tests) green. It parses `NotifyConfigSchema` at execute time. - Ablations (each mutation went through `scripts/ablation-replace.mjs`, which checks the anchor hit and that restore left `git diff HEAD` empty): - lint `if (type !== EMAIL_FIELD_TYPE)` to `if (false)`: 3 tests red (not-email, nested region, publish door). Restored, blob == HEAD. - spec `if (channels.length !== 1 || channels[0] !== 'email')` to `if (false)`: 4 tests red. Restored, blob == HEAD. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `433f7f2005`: **113 commands, every one exit 0**. Each exit code was captured to a file before any pipe. `--ran` reconciliation: "113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED (a DERIVED zero — all 113 recorded an exit code and none of them is 3)". - The first pass, on `8040d181`, had three prerequisite refusals (exit 3): `check-plugin-teardown-shape --self-test` (shallow clone), `check:skill-examples` (client packages not built) and `check:dual-build-cjs-loads` (no dist). It also had one real finding: `check:spec-parsed-alias` (ADR-0122: a bare alias read `z.infer`). I fixed the finding in `2a26b7e90b`, unshallowed the clone, built `client` and `client-react`, and re-ran the whole list. All green. - Local-only `check:dev-prereqs`: exit 0. - `test:repo`, dogfood and the full farm are CI's. There is no `!` in the title and no `(narrowing)` arm, so no dogfood battery is owed. ## Acceptance notes - **Decided in this PR, beyond the ruling's words (reviewer, please confirm):** (1) the form is the whole `recipients` value, never a list entry; (2) the node must name `email` as its only channel. Both are narrow on purpose. Widening either later is non-breaking. Narrowing after release would be breaking. - **Not judged:** a trigger object another package declares. The runtime half resolves the field against the live object. - **No ADR edit.** ADR-0012 already maps a resolved recipient to an email address on the email channel. This PR stays off governed surfaces. - **Docs:** only the auto-generated reference changed. The hand-written `content/docs/automation/flows.mdx` recipients paragraph is not touched, so it does not advertise a form the runtime does not deliver yet. It belongs with #25571. - **Existing literal-address path (observation, not changed here):** today a plain string recipient that looks like an email, or a CEL envelope that evaluates to one, already reaches `service-messaging`'s email channel as a literal address (`email-channel.ts#resolveRecipient`, `EMAIL_SHAPE`). The `recipients` docblock lists "an email address" as an audience selector. The ruling's 「⛔ 不接受自由文本或表达式拼出的地址」 reads against that path too. Narrowing it is a breaking change outside this card. Reported to the seat in the dev report. --- _Generated by [Claude Code](https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2909fd commit 6021b5e

17 files changed

Lines changed: 682 additions & 22 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec): a `notify` recipient form that is the address in an `email`-typed field of the triggering record
7+
8+
A `notify` node's `recipients` takes a fourth form, as the whole value: `{ emailField: 'applicant_email' }`. It names an `email`-typed field of the record that triggered the flow, and the address that field holds is the recipient — for a person who is not a user, such as an applicant who came in through a public form. The field is named by its machine name, so neither an address written out nor an expression that builds one can parse as this form: a string in `recipients` stays a user id or an audience selector, and an envelope stays a CEL value.
9+
10+
```ts
11+
{
12+
type: 'notify',
13+
config: {
14+
recipients: { emailField: 'contact_email' },
15+
channels: 'email',
16+
template: 'supplier.application_received',
17+
},
18+
}
19+
```
20+
21+
- The node names `email` as its only channel (`channels: 'email'` or `['email']`). An address that no user holds has no inbox, so a node with no `channels`, or one naming any other channel, is refused.
22+
- The form is the whole `recipients` value, never a list entry.
23+
- `@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.
24+
25+
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.
26+
27+
Clause-②: yes (widening)
28+
29+
Metadata-protocol: added

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Io Node Config — Automation Protocol reference
33
navTitle: Io Node Config
4-
description: "Config contracts for the flat IO builtins — notify and http. Reference for HttpConfig, NotifyConfig: every property with its type and default."
4+
description: "Config contracts for the flat IO builtins — notify and http. Reference for HttpConfig, NotifyConfig and 1 more: every property with its type and default."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -63,8 +63,8 @@ sibling block — see the descriptor note in
6363
## TypeScript Usage
6464

6565
```typescript
66-
import { HttpConfigSchema, NotifyConfigSchema } from '@objectstack/spec/automation';
67-
import type { HttpConfig, NotifyConfig } from '@objectstack/spec/automation';
66+
import { HttpConfigSchema, NotifyConfigSchema, NotifyEmailFieldRecipientSchema } from '@objectstack/spec/automation';
67+
import type { HttpConfig, NotifyConfig, NotifyEmailFieldRecipient } from '@objectstack/spec/automation';
6868

6969
// Validate data
7070
const result = HttpConfigSchema.parse(data);
@@ -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 }` | ✅ | 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. |
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. |
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. |
@@ -109,6 +109,27 @@ const result = HttpConfigSchema.parse(data);
109109
| **actionUrl** | `string` | optional | Explicit click-through URL, rendered per run with `{{ }}` holes like `title` (`/crm_case/{{ record.id }}`); a single-brace `{token}` is refused. Overrides the link synthesized from sourceObject/sourceId. |
110110
| **payload** | `Record<string, any>` | optional | Extra values merged into the notification payload — literals, read as written: a single-brace `{token}` in any string of one is not resolved here and is refused, as is a CEL value envelope written as a value; write the value |
111111

112+
### Nested Shape: `NotifyConfig.recipients[option 4]`
113+
114+
An email-field recipient: the address in an `email`-typed field of the record that triggered the flow — the field is named, never an address or an expression
115+
116+
| Property | Type | Required | Description |
117+
| :--- | :--- | :--- | :--- |
118+
| **emailField** | `string` | ✅ | Machine name of an `email`-typed field of the record that triggered the flow; the address it holds is the recipient |
119+
120+
121+
---
122+
123+
## NotifyEmailFieldRecipient
124+
125+
An email-field recipient: the address in an `email`-typed field of the record that triggered the flow — the field is named, never an address or an expression
126+
127+
### Properties
128+
129+
| Property | Type | Required | Description |
130+
| :--- | :--- | :--- | :--- |
131+
| **emailField** | `string` | ✅ | Machine name of an `email`-typed field of the record that triggered the flow; the address it holds is the recipient |
132+
112133

113134
---
114135

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1547 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1548 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -22,7 +22,7 @@ counts are sums of the rows they head. Regenerate with
2222
| :--- | ---: | ---: | :--- |
2323
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
2424
| [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. |
25-
| [Automation Protocol](/docs/references/automation) | 13 | 71 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
25+
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
2626
| [Data Protocol](/docs/references/data) | 29 | 178 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
2727
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
2828
| [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
@@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 36 | 285 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **197** | **1547** | 14 protocol modules |
37+
| **Total** | **197** | **1548** | 14 protocol modules |
3838

3939
---
4040

@@ -106,7 +106,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
106106

107107
## Automation Protocol
108108

109-
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 71 schemas**
109+
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**
110110

111111
Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.
112112

@@ -119,7 +119,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu
119119
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary` |
120120
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
121121
| [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect`, `FlowFunctionLoweredDeclaration` |
122-
| [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` |
122+
| [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig`, `NotifyEmailFieldRecipient` |
123123
| [`node-executor.zod.ts`](/docs/references/automation/node-executor) | `ActionCategory`, `ActionDescriptor`, `ActionParadigm`, `NodeExecutorDescriptor`, `WaitEventType`, `WaitExecutorConfig`, `WaitResumePayload`, `WaitTimeoutBehavior` |
124124
| [`schedule-organization.zod.ts`](/docs/references/automation/schedule-organization) | `ScheduleOrganization` |
125125
| [`schemaless-node-config.zod.ts`](/docs/references/automation/schemaless-node-config) | `DecisionCondition`, `DecisionConfig`, `ScriptConfig`, `SubflowConfig` |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `automation/` | 61 | 37 | 0 | 1 | 23 |
24+
| `automation/` | 62 | 38 | 0 | 1 | 23 |
2525

2626
## `automation/` — sites
2727

@@ -38,20 +38,20 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3838
| `execution.zod.ts` | 12 |
3939
| `flow-function.zod.ts` | 1 |
4040
| `flow.zod.ts` | 11 |
41-
| `io-node-config.zod.ts` | 2 |
41+
| `io-node-config.zod.ts` | 3 |
4242
| `node-executor.zod.ts` | 4 |
4343
| `schemaless-node-config.zod.ts` | 4 |
4444
| `time-relative-trigger.zod.ts` | 1 |
4545
| `webhook.zod.ts` | 1 |
46-
| **total** | **61** |
46+
| **total** | **62** |
4747

4848
## `automation/` — open
4949

5050
Per file, how many of its sites still silently discard unknown keys. The `Class`
5151
column that decides the bucket split is hand-written in the ledger; the arithmetic
5252
over it is here.
5353

54-
**23 strip of 61**, in 5 file(s).
54+
**23 strip of 62**, in 5 file(s).
5555

5656
| File | Strip | Sites |
5757
|---|---|---|
@@ -60,7 +60,7 @@ over it is here.
6060
| `execution.zod.ts` | 12 | 12 |
6161
| `flow.zod.ts` | 1 | 11 |
6262
| `node-executor.zod.ts` | 4 | 4 |
63-
| **total** | **23** | **61** |
63+
| **total** | **23** | **62** |
6464

6565
| Bucket | Sites |
6666
|---|---|

‎packages/lint/src/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,16 @@ export type {
855855
FlowWriteNodeDeferral,
856856
} from './validate-flow-node-writes.js';
857857

858+
// A `notify` node's email-field recipient (`{ emailField }`) against the
859+
// trigger object's declared fields: the field must exist and be `email`-typed.
860+
export {
861+
validateNotifyEmailFieldRecipients,
862+
NOTIFY_EMAIL_FIELD_UNKNOWN,
863+
NOTIFY_EMAIL_FIELD_NOT_EMAIL,
864+
NOTIFY_EMAIL_FIELD_NO_TRIGGER_RECORD,
865+
} from './validate-notify-email-field-recipients.js';
866+
export type { NotifyEmailFieldRecipientFinding } from './validate-notify-email-field-recipients.js';
867+
858868
// One entry point for the reference-resolution rules above (#3583 §5 D5).
859869
// Adding a rule to `REFERENCE_INTEGRITY_RULES` runs it on `validate`, `lint`
860870
// and `compile` at once — the CLI call sites do not change.

‎packages/lint/src/reference-integrity-suite.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ describe('reference-integrity suite — membership', () => {
7070
'validateHookBodyWrites',
7171
'validateActionBodyWrites',
7272
'validateFlowNodeWrites',
73+
// [ruling 6377731715] A notify email-field recipient, resolved against
74+
// the trigger object's declared fields — placed beside the member that
75+
// resolves the same flow's CRUD write maps against that object.
76+
'validateNotifyEmailFieldRecipients',
7377
'validateReadonlyFlowWrites',
7478
// [#13653] The hook-side half of the readonly write judgement: a body's
7579
// `ctx.api` update to a declared-`readonly` field, placed beside the flow
@@ -348,6 +352,14 @@ describe('reference-integrity suite — every member actually runs', () => {
348352
type: 'update_record',
349353
config: { objectName: 'crm_lead', fields: { locked: true } },
350354
},
355+
// validateNotifyEmailFieldRecipients: an email-field recipient naming
356+
// a field crm_lead does not declare — no address to send to, so it
357+
// gates.
358+
{
359+
id: 'tell_lead',
360+
type: 'notify',
361+
config: { title: 'Thanks', channels: 'email', recipients: { emailField: 'contact_email' } },
362+
},
351363
],
352364
},
353365
],
@@ -379,6 +391,7 @@ describe('reference-integrity suite — every member actually runs', () => {
379391
// comments on why these ride along instead of becoming their own entries.
380392
expect(rules).toContain('action-record-write-discarded');
381393
expect(rules).toContain('flow-node-write-unknown-field');
394+
expect(rules).toContain('notify-email-field-unknown');
382395
expect(rules).toContain('flow-update-readonly-field');
383396
expect(rules).toContain('hook-api-update-readonly-field');
384397
expect(rules).toContain('action-api-update-readonly-when-field');

‎packages/lint/src/reference-integrity-suite.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import { validateAiAgentAuthoring } from './validate-ai-agent-authoring.js';
123123
import { validateHookBodyWrites } from './validate-hook-body-writes.js';
124124
import { validateActionBodyWrites } from './validate-action-body-writes.js';
125125
import { validateFlowNodeWrites } from './validate-flow-node-writes.js';
126+
import { validateNotifyEmailFieldRecipients } from './validate-notify-email-field-recipients.js';
126127
import { validateReadonlyFlowWrites } from './validate-readonly-flow-writes.js';
127128
import { validateReadonlyHookWrites } from './validate-readonly-hook-writes.js';
128129
import { validateReadonlyActionWrites } from './validate-readonly-action-writes.js';
@@ -589,6 +590,12 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
589590
// standing "prefer a flow node, it's checked" advice was the least true of
590591
// the three until it landed.
591592
{ name: 'validateFlowNodeWrites', run: validateFlowNodeWrites },
593+
// [ruling 6377731715, letter B] A `notify` node's email-field recipient,
594+
// `{ emailField }`, names a field of the triggering record whose address is
595+
// the recipient — resolved here against the trigger object's declared
596+
// fields, and refused unless it is `email`-typed. A literal name against a
597+
// literal object, so it gates; the `flow` default covers the publish door.
598+
{ name: 'validateNotifyEmailFieldRecipients', run: validateNotifyEmailFieldRecipients },
592599
// The OTHER question about that same `config.fields` map: not "does this
593600
// field exist?" but "is it writable?" — a `runAs:'user'` update_record
594601
// writing a static-`readonly` field is stripped by the engine and the step

0 commit comments

Comments
 (0)