Skip to content
Merged
29 changes: 29 additions & 0 deletions .changeset/25610-notify-email-field-recipient.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

feat(spec): a `notify` recipient form that is the address in an `email`-typed field of the triggering record

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.

```ts
{
type: 'notify',
config: {
recipients: { emailField: 'contact_email' },
channels: 'email',
template: 'supplier.application_received',
},
}
```

- 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.
- 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.

Clause-②: yes (widening)

Metadata-protocol: added
29 changes: 25 additions & 4 deletions content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Io Node Config — Automation Protocol reference
navTitle: Io Node Config
description: "Config contracts for the flat IO builtins — notify and http. Reference for HttpConfig, NotifyConfig: every property with its type and default."
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."
---

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

```typescript
import { HttpConfigSchema, NotifyConfigSchema } from '@objectstack/spec/automation';
import type { HttpConfig, NotifyConfig } from '@objectstack/spec/automation';
import { HttpConfigSchema, NotifyConfigSchema, NotifyEmailFieldRecipientSchema } from '@objectstack/spec/automation';
import type { HttpConfig, NotifyConfig, NotifyEmailFieldRecipient } from '@objectstack/spec/automation';

// Validate data
const result = HttpConfigSchema.parse(data);
Expand Down Expand Up @@ -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 }` | ✅ | 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. |
| **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. |
| **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. |
Expand All @@ -109,6 +109,27 @@ const result = HttpConfigSchema.parse(data);
| **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. |
| **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 |

### Nested Shape: `NotifyConfig.recipients[option 4]`

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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **emailField** | `string` | ✅ | Machine name of an `email`-typed field of the record that triggered the flow; the address it holds is the recipient |


---

## NotifyEmailFieldRecipient

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

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **emailField** | `string` | ✅ | Machine name of an `email`-typed field of the record that triggered the flow; the address it holds is the recipient |


---

10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
description: Every schema published by @objectstack/spec — 1547 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1548 schemas across 14 protocol modules
---

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

---

Expand Down Expand Up @@ -106,7 +106,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

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

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

Expand All @@ -119,7 +119,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
| [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect`, `FlowFunctionLoweredDeclaration` |
| [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` |
| [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig`, `NotifyEmailFieldRecipient` |
| [`node-executor.zod.ts`](/docs/references/automation/node-executor) | `ActionCategory`, `ActionDescriptor`, `ActionParadigm`, `NodeExecutorDescriptor`, `WaitEventType`, `WaitExecutorConfig`, `WaitResumePayload`, `WaitTimeoutBehavior` |
| [`schedule-organization.zod.ts`](/docs/references/automation/schedule-organization) | `ScheduleOrganization` |
| [`schemaless-node-config.zod.ts`](/docs/references/automation/schemaless-node-config) | `DecisionCondition`, `DecisionConfig`, `ScriptConfig`, `SubflowConfig` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `automation/` | 61 | 37 | 0 | 1 | 23 |
| `automation/` | 62 | 38 | 0 | 1 | 23 |

## `automation/` — sites

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

## `automation/` — open

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

**23 strip of 61**, in 5 file(s).
**23 strip of 62**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All @@ -60,7 +60,7 @@ over it is here.
| `execution.zod.ts` | 12 | 12 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **23** | **61** |
| **total** | **23** | **62** |

| Bucket | Sites |
|---|---|
Expand Down
10 changes: 10 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,16 @@ export type {
FlowWriteNodeDeferral,
} from './validate-flow-node-writes.js';

// A `notify` node's email-field recipient (`{ emailField }`) against the
// trigger object's declared fields: the field must exist and be `email`-typed.
export {
validateNotifyEmailFieldRecipients,
NOTIFY_EMAIL_FIELD_UNKNOWN,
NOTIFY_EMAIL_FIELD_NOT_EMAIL,
NOTIFY_EMAIL_FIELD_NO_TRIGGER_RECORD,
} from './validate-notify-email-field-recipients.js';
export type { NotifyEmailFieldRecipientFinding } from './validate-notify-email-field-recipients.js';

// One entry point for the reference-resolution rules above (#3583 §5 D5).
// Adding a rule to `REFERENCE_INTEGRITY_RULES` runs it on `validate`, `lint`
// and `compile` at once — the CLI call sites do not change.
Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ describe('reference-integrity suite — membership', () => {
'validateHookBodyWrites',
'validateActionBodyWrites',
'validateFlowNodeWrites',
// [ruling 6377731715] A notify email-field recipient, resolved against
// the trigger object's declared fields — placed beside the member that
// resolves the same flow's CRUD write maps against that object.
'validateNotifyEmailFieldRecipients',
'validateReadonlyFlowWrites',
// [#13653] The hook-side half of the readonly write judgement: a body's
// `ctx.api` update to a declared-`readonly` field, placed beside the flow
Expand Down Expand Up @@ -348,6 +352,14 @@ describe('reference-integrity suite — every member actually runs', () => {
type: 'update_record',
config: { objectName: 'crm_lead', fields: { locked: true } },
},
// validateNotifyEmailFieldRecipients: an email-field recipient naming
// a field crm_lead does not declare — no address to send to, so it
// gates.
{
id: 'tell_lead',
type: 'notify',
config: { title: 'Thanks', channels: 'email', recipients: { emailField: 'contact_email' } },
},
],
},
],
Expand Down Expand Up @@ -379,6 +391,7 @@ describe('reference-integrity suite — every member actually runs', () => {
// comments on why these ride along instead of becoming their own entries.
expect(rules).toContain('action-record-write-discarded');
expect(rules).toContain('flow-node-write-unknown-field');
expect(rules).toContain('notify-email-field-unknown');
expect(rules).toContain('flow-update-readonly-field');
expect(rules).toContain('hook-api-update-readonly-field');
expect(rules).toContain('action-api-update-readonly-when-field');
Expand Down
7 changes: 7 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import { validateAiAgentAuthoring } from './validate-ai-agent-authoring.js';
import { validateHookBodyWrites } from './validate-hook-body-writes.js';
import { validateActionBodyWrites } from './validate-action-body-writes.js';
import { validateFlowNodeWrites } from './validate-flow-node-writes.js';
import { validateNotifyEmailFieldRecipients } from './validate-notify-email-field-recipients.js';
import { validateReadonlyFlowWrites } from './validate-readonly-flow-writes.js';
import { validateReadonlyHookWrites } from './validate-readonly-hook-writes.js';
import { validateReadonlyActionWrites } from './validate-readonly-action-writes.js';
Expand Down Expand Up @@ -589,6 +590,12 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// standing "prefer a flow node, it's checked" advice was the least true of
// the three until it landed.
{ name: 'validateFlowNodeWrites', run: validateFlowNodeWrites },
// [ruling 6377731715, letter B] A `notify` node's email-field recipient,
// `{ emailField }`, names a field of the triggering record whose address is
// the recipient — resolved here against the trigger object's declared
// fields, and refused unless it is `email`-typed. A literal name against a
// literal object, so it gates; the `flow` default covers the publish door.
{ name: 'validateNotifyEmailFieldRecipients', run: validateNotifyEmailFieldRecipients },
// The OTHER question about that same `config.fields` map: not "does this
// field exist?" but "is it writable?" — a `runAs:'user'` update_record
// writing a static-`readonly` field is stripped by the engine and the step
Expand Down
Loading
Loading