From 2532aaaea33bdfca51769f631cb982ea09b0b044 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:59:55 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(spec):=20notify=20email-field=20recipi?= =?UTF-8?q?ent=20form=20=E2=80=94=20recipients:=20{=20emailField=20}=20(#2?= =?UTF-8?q?5610)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude --- .../src/automation/io-node-config.test.ts | 4 +- .../spec/src/automation/io-node-config.zod.ts | 119 +++++++++++++++- .../notify-email-field-recipient.test.ts | 128 ++++++++++++++++++ 3 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 packages/spec/src/automation/notify-email-field-recipient.test.ts diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index 177afe66766..d50ba9485d1 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -741,7 +741,9 @@ describe('NotifyConfigSchema — the value slots, the literals and the `actionUr } expect(json.properties?.templateData?.additionalProperties?.xExpression).toBe('value'); // The literal arms stay in the published shape, beside the envelope. - expect(json.properties?.recipients?.anyOf?.map((arm) => arm.type)).toEqual(['string', 'array', 'object']); + // The fourth arm is the email-field recipient, `{ emailField }` — a closed object, no envelope. + expect(json.properties?.recipients?.anyOf?.map((arm) => arm.type)).toEqual(['string', 'array', 'object', 'object']); + expect(Object.keys(json.properties?.recipients?.anyOf?.[3]?.properties ?? {})).toEqual(['emailField']); expect(json.properties?.sourceId?.anyOf?.map((arm) => arm.type)).toEqual(['string', 'object']); // The literals publish their 17.x shapes, with no marker and no envelope arm. for (const key of ['sourceObject', 'actorId']) { diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 820f4c4af7e..9226d76bb9f 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -246,14 +246,85 @@ function notifyValuePhrase(value: unknown): string { return `a ${typeof value}`; } +// ─── 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 +// `validateNotifyEmailFieldRecipients`, a reference-integrity rule, so every +// authoring command and the runtime publish door refuse it. +// +// 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. + +/** A field's machine name — the grammar of `FieldSchema.name`, which `emailField` names. */ +const FIELD_MACHINE_NAME = /^[a-z_][a-z0-9_]*$/; + +/** What an email-field recipient is — the sentence its refusals lead with. */ +const NOTIFY_EMAIL_FIELD_RULE = + 'An email-field recipient, `{ emailField: \'applicant_email\' }`, names an `email`-typed field of the record ' + + 'that triggered the flow, and the address in that field is the recipient. It names the field by its machine ' + + 'name: never an address written out, and never an expression that builds one.'; + +/** + * `notify.recipients` as `{ emailField }` — the address in an `email`-typed + * field of the triggering record (see the section note above). + */ +export const NotifyEmailFieldRecipientSchema = strictObject({ + surface: 'this notify email-field recipient', + history: + 'The form was closed from the day it was declared: a key beside `emailField` would otherwise be dropped, ' + + 'and the node would mail an address the author did not choose.', +}, { + emailField: z.string({ error: () => `${NOTIFY_EMAIL_FIELD_RULE} \`emailField\` is a field name, as a string.` }) + .regex(FIELD_MACHINE_NAME, { + error: () => `${NOTIFY_EMAIL_FIELD_RULE} \`emailField\` is not a field's machine name (lowercase \`snake_case\`).`, + }) + .describe('Machine name of an `email`-typed field of the record that triggered the flow; the address it holds is the recipient'), +}).meta({ + description: + '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', +}); + +export type NotifyEmailFieldRecipient = z.infer; + +/** True when `value` is written as an email-field recipient — a plain object with an `emailField` key. */ +function isEmailFieldRecipientShaped(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.prototype.hasOwnProperty.call(value, 'emailField') && !isExpressionEnvelopeShaped(value); +} + +/** An email-field recipient's refusal — {@link NOTIFY_EMAIL_FIELD_RULE}, then each issue, located. */ +function emailFieldRecipientRefusal(value: unknown): string { + const result = NotifyEmailFieldRecipientSchema.safeParse(value); + if (result.success) return NOTIFY_EMAIL_FIELD_RULE; + const details = result.error.issues.map((i) => { + const message = i.message.startsWith(NOTIFY_EMAIL_FIELD_RULE) ? i.message.slice(NOTIFY_EMAIL_FIELD_RULE.length + 1) : i.message; + return i.path.length > 0 ? `\`${i.path.map(String).join('.')}\`: ${message}` : message; + }); + return [NOTIFY_EMAIL_FIELD_RULE, ...details].join(' '); +} + /** * What `recipients` holds — the sentence every refusal of a value there that is - * neither a recipient, a list of them nor a CEL value envelope leads with. + * neither a recipient, a list of them, a CEL value envelope nor an email-field + * 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; or one CEL value ' - + 'envelope, `{ dialect: \'cel\', source: \'…\' }`, that evaluates to a recipient or a list of them.'; + + '`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 ' + + 'email-field recipient, `{ emailField: \'applicant_email\' }`, the address in an `email`-typed field of the ' + + 'record that triggered the flow.'; /** Why a `recipients` value the union refuses is refused — naming the list entry that is not one. */ function notifyRecipientsRefusal(input: unknown): string { @@ -262,10 +333,16 @@ function notifyRecipientsRefusal(input: unknown): string { && !(isExpressionEnvelopeShaped(entry) && AssignmentExpressionValueSchema.safeParse(entry).success)); const entry = input[index]; if (isExpressionEnvelopeShaped(entry)) return envelopeRefusal(entry, `[${index}]`); + if (isEmailFieldRecipientShaped(entry)) { + return `${NOTIFY_EMAIL_FIELD_RULE} The entry at \`[${index}]\` is one, and an email-field recipient is the ` + + 'whole `recipients` value, never a list entry: give the address its own `notify` node, with ' + + '`channels: \'email\'`.'; + } return `${NOTIFY_RECIPIENTS_RULE} The entry at \`[${index}]\` is ${notifyValuePhrase(entry)}: a list entry is a ` + 'recipient string or an envelope.'; } if (isExpressionEnvelopeShaped(input)) return envelopeRefusal(input, ''); + if (isEmailFieldRecipientShaped(input)) return emailFieldRecipientRefusal(input); return `${NOTIFY_RECIPIENTS_RULE} This one is ${notifyValuePhrase(input)}.`; } @@ -280,7 +357,10 @@ function notifyRecipientsRefusal(input: unknown): string { * (a multi-user field, a list variable) adds EACH of its recipients, so a * list is never handed on as one joined recipient; * - a **CEL value envelope** ({@link AssignmentExpressionValueSchema}) is - * evaluated per run to a recipient or a list of them. + * evaluated per run to a recipient or a list of them; + * - an **email-field recipient** ({@link NotifyEmailFieldRecipientSchema}), + * the whole value only, names an `email`-typed field of the triggering + * record whose address is the recipient. * * 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 @@ -293,6 +373,7 @@ const NotifyRecipientsSlotSchema = z.union([ z.string(), z.array(z.union([z.string(), AssignmentExpressionValueSchema])), AssignmentExpressionValueSchema, + NotifyEmailFieldRecipientSchema, ], { error: (issue) => notifyRecipientsRefusal((issue as { input?: unknown }).input), }) @@ -308,8 +389,9 @@ const NotifyRecipientsSlotSchema = z.union([ description: '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); or one envelope evaluating to a ' - + 'recipient or a list — a `{…}` template token is refused', + + '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', xExpression: 'value', }); @@ -549,7 +631,11 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ '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.', + + '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.', ), /** * Inline notification title — the NON-localizable content path, and a @@ -677,6 +763,25 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ + 'nothing would ever read it. Add the `template` it feeds, or delete `templateData`.', }); } + // [ruling 6377731715, letter B] An email-field recipient is an address that + // no user holds, so it has no inbox, no SMS number and no preference row: + // the email channel is the one that can deliver to it. The node names that + // channel and no other, so no channel it would silently skip is written. + if (typeof cfg.recipients === 'object' && !Array.isArray(cfg.recipients) && 'emailField' in cfg.recipients) { + const channels = cfg.channels === undefined ? [] : Array.isArray(cfg.channels) ? cfg.channels : [cfg.channels]; + if (channels.length !== 1 || channels[0] !== 'email') { + ctx.addIssue({ + code: 'custom', + path: ['channels'], + message: + '`recipients` is an email-field recipient — an address that no user holds — so the email channel is the ' + + 'only one that can deliver to it, and this node must name it alone: write `channels: \'email\'`. ' + + (cfg.channels === undefined + ? 'With no `channels`, the node would fan out to the inbox, which an address has none of.' + : `This node names ${channels.length === 0 ? 'no channel' : channels.map((c) => `\`${c}\``).join(', ')}.`), + }); + } + } if (cfg.template === undefined && cfg.title === undefined) { ctx.addIssue({ code: 'custom', diff --git a/packages/spec/src/automation/notify-email-field-recipient.test.ts b/packages/spec/src/automation/notify-email-field-recipient.test.ts new file mode 100644 index 00000000000..ae7ca476c19 --- /dev/null +++ b/packages/spec/src/automation/notify-email-field-recipient.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `notify` email-field recipient (ruling 6377731715 on #25496, letter B; + * #25610): `recipients: { emailField }` names an `email`-typed field of the + * triggering record, and the address it holds is the recipient. ⛔ Never a + * free string, never an expression that builds an address. + * + * Pinned here: the form parses; a string and an envelope never parse AS it; + * every malformed spelling of it is refused with its own reason; it is the + * whole value, never a list entry; and the node names `email` as its only + * channel. Whether the named field exists and is `email`-typed is the + * reference-integrity rule's question (`@objectstack/lint`, + * `validateNotifyEmailFieldRecipients`) and is pinned there. + */ + +import { describe, expect, it } from 'vitest'; + +import { NotifyConfigSchema, NotifyEmailFieldRecipientSchema } from './io-node-config.zod.js'; +import { FlowSchema } from './flow.zod.js'; + +const BASE = { title: 'Your application was received', channels: 'email' } as const; + +/** Every issue message of a refused config, or `[]` when it parsed. */ +function messages(value: unknown): string[] { + const result = NotifyConfigSchema.safeParse(value); + return result.success ? [] : result.error.issues.map((i) => i.message); +} + +describe('notify email-field recipient — the form', () => { + it('parses as the whole `recipients` value and keeps its shape', () => { + const result = NotifyConfigSchema.safeParse({ ...BASE, recipients: { emailField: 'applicant_email' } }); + expect(result.success).toBe(true); + expect(result.success && result.data.recipients).toEqual({ emailField: 'applicant_email' }); + }); + + it('parses through a whole flow, in a notify node', () => { + const result = FlowSchema.safeParse({ + name: 'application_received', + label: 'Application received', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'supplier_application', triggerType: 'record-after-create' } }, + { id: 'tell_applicant', type: 'notify', label: 'Tell the applicant', config: { ...BASE, recipients: { emailField: 'contact_email' } } }, + ], + edges: [{ id: 'e1', source: 'start', target: 'tell_applicant' }], + }); + expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); + }); + + it('accepts `channels` written as a one-entry list too', () => { + expect(messages({ ...BASE, channels: ['email'], recipients: { emailField: 'applicant_email' } })).toEqual([]); + }); +}); + +describe('notify email-field recipient — a free string or an expression never parses as it', () => { + it.each([ + ['an address written out', 'applicant@example.com'], + ['a field name written bare', 'applicant_email'], + ['a CEL value envelope', { dialect: 'cel', source: 'record.applicant_email' }], + ])('%s is not an email-field recipient', (_label, value) => { + expect(NotifyEmailFieldRecipientSchema.safeParse(value).success).toBe(false); + }); + + it('a string in `recipients` stays a string recipient (a user id or audience selector), not this form', () => { + const result = NotifyConfigSchema.safeParse({ title: 't', recipients: 'applicant_email' }); + expect(result.success && result.data.recipients).toBe('applicant_email'); + }); + + it('an envelope in `recipients` stays a CEL value, not this form', () => { + const result = NotifyConfigSchema.safeParse({ title: 't', recipients: { dialect: 'cel', source: 'record.owner' } }); + expect(result.success && result.data.recipients).toEqual({ dialect: 'cel', source: 'record.owner' }); + }); + + it('refuses an expression written as the field name — `emailField` holds a name, never a value', () => { + const issues = messages({ ...BASE, recipients: { emailField: { dialect: 'cel', source: 'record.a + \'@x.com\'' } } }); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatch(/^An email-field recipient, /); + expect(issues[0]).toContain('`emailField` is a field name, as a string.'); + }); + + it('refuses an address written as the field name', () => { + const issues = messages({ ...BASE, recipients: { emailField: 'applicant@example.com' } }); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatch(/^An email-field recipient, /); + expect(issues[0]).toContain('is not a field\'s machine name'); + }); + + it('refuses a key beside `emailField`, naming it', () => { + const result = NotifyConfigSchema.safeParse({ ...BASE, recipients: { emailField: 'applicant_email', fallback: 'ops@example.com' } }); + expect(result.success).toBe(false); + const message = result.success ? '' : result.error.issues[0].message; + expect(message).toMatch(/^An email-field recipient, /); + expect(message).toContain('fallback'); + }); +}); + +describe('notify email-field recipient — the whole value, delivered by email', () => { + it('refuses it as a list entry', () => { + const issues = messages({ ...BASE, recipients: ['team:ops', { emailField: 'applicant_email' }] }); + expect(issues).toHaveLength(1); + expect(issues[0]).toContain('The entry at `[1]` is one, and an email-field recipient is the whole `recipients` value'); + }); + + it('refuses a node with no `channels` — the default inbox has no address to deliver to', () => { + const result = NotifyConfigSchema.safeParse({ title: 't', recipients: { emailField: 'applicant_email' } }); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('custom'); + expect(issue?.path).toEqual(['channels']); + expect(issue?.message).toMatch(/^`recipients` is an email-field recipient/); + }); + + it.each([ + ['inbox', 'inbox'], + ['email beside another channel', ['email', 'inbox']], + ['an empty list', []], + ])('refuses `channels` naming %s', (_label, channels) => { + const result = NotifyConfigSchema.safeParse({ title: 't', channels, recipients: { emailField: 'applicant_email' } }); + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((i) => i.path.join('.'))).toEqual(['channels']); + }); + + it('leaves `channels` free for every other recipient form', () => { + expect(messages({ title: 't', recipients: 'u1', channels: ['inbox', 'email'] })).toEqual([]); + expect(messages({ title: 't', recipients: 'u1' })).toEqual([]); + }); +}); From 90752616b1ef5d7f96543b23dcd518d6c17377bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:01:37 +0000 Subject: [PATCH 2/6] feat(lint): refuse a notify email-field recipient that names no email-typed trigger field Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude --- packages/lint/src/index.ts | 10 + .../src/reference-integrity-suite.test.ts | 13 ++ .../lint/src/reference-integrity-suite.ts | 7 + ...date-notify-email-field-recipients.test.ts | 126 +++++++++++ .../validate-notify-email-field-recipients.ts | 208 ++++++++++++++++++ 5 files changed, 364 insertions(+) create mode 100644 packages/lint/src/validate-notify-email-field-recipients.test.ts create mode 100644 packages/lint/src/validate-notify-email-field-recipients.ts diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 3682a92d1d5..e71c5206f08 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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. diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index 0c5941c0071..da920f347f6 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -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 @@ -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' } }, + }, ], }, ], @@ -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'); diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index f76304a9c64..cc3e3f930a9 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -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'; @@ -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 diff --git a/packages/lint/src/validate-notify-email-field-recipients.test.ts b/packages/lint/src/validate-notify-email-field-recipients.test.ts new file mode 100644 index 00000000000..a423b35c8a4 --- /dev/null +++ b/packages/lint/src/validate-notify-email-field-recipients.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The author-time half of the `notify` email-field recipient (ruling +// 6377731715 on #25496, letter B): `{ emailField }` must name an `email`-typed +// field of the record that triggered the flow. Each verdict is pinned by rule +// id, severity, path and the first clause of its message; the clean case and +// the unjudgeable skips are pinned beside them. + +import { describe, expect, it } from 'vitest'; + +import { + validateNotifyEmailFieldRecipients, + NOTIFY_EMAIL_FIELD_UNKNOWN, + NOTIFY_EMAIL_FIELD_NOT_EMAIL, + NOTIFY_EMAIL_FIELD_NO_TRIGGER_RECORD, +} from './validate-notify-email-field-recipients.js'; +import { validateReferenceIntegrity } from './reference-integrity-suite.js'; + +const application = { + name: 'supplier_application', + fields: { + company: { type: 'text', label: 'Company' }, + contact_email: { type: 'email', label: 'Contact email' }, + billing_email: { type: 'email', label: 'Billing email' }, + website: { type: 'url', label: 'Website' }, + }, +}; + +const notify = (emailField: string, id = 'tell_applicant') => ({ + id, + type: 'notify', + label: 'Tell the applicant', + config: { title: 'Your application was received', channels: 'email', recipients: { emailField } }, +}); + +const recordFlow = (...nodes: object[]) => ({ + name: 'application_received', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'supplier_application', triggerType: 'record-after-create' } }, + ...nodes, + ], +}); + +describe('validateNotifyEmailFieldRecipients', () => { + it('passes an `email`-typed field of the trigger object', () => { + expect(validateNotifyEmailFieldRecipients({ objects: [application], flows: [recordFlow(notify('contact_email'))] })).toEqual([]); + }); + + it('passes an `email`-typed field of a time-relative sweep\'s object', () => { + const flow = { + name: 'application_reminder', + nodes: [ + { id: 'start', type: 'start', config: { timeRelative: { object: 'supplier_application', field: 'created_at', offset: '7d' } } }, + notify('billing_email'), + ], + }; + expect(validateNotifyEmailFieldRecipients({ objects: [application], flows: [flow] })).toEqual([]); + }); + + it('refuses a field the trigger object does not declare', () => { + const [finding, ...rest] = validateNotifyEmailFieldRecipients({ objects: [application], flows: [recordFlow(notify('contact_emial'))] }); + expect(rest).toEqual([]); + expect(finding.rule).toBe(NOTIFY_EMAIL_FIELD_UNKNOWN); + expect(finding.severity).toBe('error'); + expect(finding.path).toBe('flows[0].nodes[1].config.recipients.emailField'); + expect(finding.where).toBe('flow "application_received" › node "Tell the applicant"'); + expect(finding.message).toMatch(/^notify recipient `\{ emailField: 'contact_emial' \}` names a field of the record that triggered the flow, and object 'supplier_application' declares no field 'contact_emial'/); + expect(finding.hint).toContain('contact_email'); + }); + + it('refuses a declared field that is not `email`-typed', () => { + const [finding, ...rest] = validateNotifyEmailFieldRecipients({ objects: [application], flows: [recordFlow(notify('website'))] }); + expect(rest).toEqual([]); + expect(finding.rule).toBe(NOTIFY_EMAIL_FIELD_NOT_EMAIL); + expect(finding.severity).toBe('error'); + expect(finding.path).toBe('flows[0].nodes[1].config.recipients.emailField'); + expect(finding.message).toContain("'supplier_application.website' is typed 'url', not `email`"); + expect(finding.hint).toBe("Name one of its `email` fields ('contact_email', 'billing_email')."); + }); + + it.each([ + ['a schedule flow', { name: 'nightly', type: 'schedule', nodes: [{ id: 'start', type: 'start', config: { schedule: '0 2 * * *' } }] }, 'it runs on a schedule'], + ['an api flow', { name: 'inbound', type: 'api', nodes: [{ id: 'start', type: 'start', config: {} }] }, 'it is triggered by an inbound api call'], + ['a record trigger with no object', { name: 'any_write', nodes: [{ id: 'start', type: 'start', config: { triggerType: 'record-after-update' } }] }, 'its record trigger names no object'], + ['a flow with no trigger', { name: 'manual', nodes: [{ id: 'start', type: 'start', config: {} }] }, 'it has no record trigger'], + ])('refuses the form in %s — no triggering record of a known object', (_label, flow, reason) => { + const withNode = { ...flow, nodes: [...flow.nodes, notify('contact_email')] }; + const [finding, ...rest] = validateNotifyEmailFieldRecipients({ objects: [application], flows: [withNode] }); + expect(rest).toEqual([]); + expect(finding.rule).toBe(NOTIFY_EMAIL_FIELD_NO_TRIGGER_RECORD); + expect(finding.severity).toBe('error'); + expect(finding.message).toContain(`this flow has no triggering record of a known object: ${reason}`); + }); + + it('judges a notify node nested in a region, at its own path', () => { + const flow = recordFlow({ + id: 'guard', + type: 'try_catch', + label: 'Guard', + config: { try: { nodes: [notify('website', 'inner')] }, catch: { nodes: [] } }, + }); + const findings = validateNotifyEmailFieldRecipients({ objects: [application], flows: [flow] }); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [NOTIFY_EMAIL_FIELD_NOT_EMAIL, 'flows[0].nodes[1].config.try.nodes[0].config.recipients.emailField'], + ]); + }); + + it('skips a trigger object this stack does not declare, and every other recipient form', () => { + const elsewhere = { ...recordFlow(notify('contact_email')), nodes: [{ id: 'start', type: 'start', config: { objectName: 'crm_lead', triggerType: 'record-after-create' } }, notify('nope')] }; + expect(validateNotifyEmailFieldRecipients({ objects: [application], flows: [elsewhere] })).toEqual([]); + const others = recordFlow( + { id: 'a', type: 'notify', config: { title: 't', recipients: 'website' } }, + { id: 'b', type: 'notify', config: { title: 't', recipients: { dialect: 'cel', source: 'record.website' } } }, + ); + expect(validateNotifyEmailFieldRecipients({ objects: [application], flows: [others] })).toEqual([]); + }); + + it('runs at the runtime publish door for a flow write', () => { + const findings = validateReferenceIntegrity( + { objects: [application], flows: [recordFlow(notify('website'))] }, + { runtimeWriteType: 'flow' }, + ); + expect(findings.filter((f) => f.rule.startsWith('notify-email-field')).map((f) => f.rule)).toEqual([NOTIFY_EMAIL_FIELD_NOT_EMAIL]); + }); +}); diff --git a/packages/lint/src/validate-notify-email-field-recipients.ts b/packages/lint/src/validate-notify-email-field-recipients.ts new file mode 100644 index 00000000000..9951fe37409 --- /dev/null +++ b/packages/lint/src/validate-notify-email-field-recipients.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Author-time refusal of a `notify` email-field recipient that does not name an +// `email`-typed field of the record that triggered the flow (ruling 6377731715 +// on #25496, letter B; #25610). +// +// `recipients: { emailField: 'applicant_email' }` (`NotifyEmailFieldRecipientSchema`, +// `@objectstack/spec/automation`) is the one recipient form that is an address +// rather than a user: the address held in that field of the triggering record — +// the applicant who came in through a public form and is nobody's user. The +// ruling's guarantee is that the address only ever comes from a field the data +// model types as `email`, ⛔ never a free string and never an expression that +// builds one. The schema makes a string or an envelope unable to parse as the +// form; which field it names, and that the field is `email`-typed, is a question +// about the flow's trigger and the object's field map together, so it is +// answered here, where those two meet — the same place `{{ record. }}` +// holes and CRUD write maps are judged against the trigger object. +// +// ─── Why every verdict GATES ───────────────────────────────────────────────── +// +// Each verdict is a certainty about literals: a literal field name, a literal +// trigger object, that object's literal field map. And the miss is never "the +// rest still works": a name that is no `email` field has no address to read, so +// the applicant hears nothing while the run looks like any other. A field of +// another type (`text`, `url`, a lookup) is the case the ruling forbids by name — +// its value was never checked as an address. +// +// ─── Which flows have a triggering record ──────────────────────────────────── +// +// Two trigger kinds hand the run ONE record of a known object as `record`: +// a record trigger (`record_change`, the start node's `objectName`) and a +// time-relative sweep (`time_relative`, `timeRelative.object`, one run per +// matched record). Every other kind has no triggering record whose fields a +// data model types — a schedule has none, an inbound `api` hook hands in the +// request body — so the form is refused there, by name. So is a record trigger +// that names no object: it fires on a write to ANY object, and no one field +// is `email`-typed on all of them. +// +// Skipped, silently, like the rest of the field-existence family +// (`judgeableFieldsOf`): a trigger object this stack does not declare (another +// package's, a `sys_*` object) and one that declares no fields at all — a field +// map that cannot be seen cannot be judged. The runtime half resolves the field +// against the live object and fails the node when it holds no address. +// +// Wired via REFERENCE_INTEGRITY_RULES (it resolves a field NAME written in +// metadata against what the stack declares), so `os validate`, `os lint`, +// `os compile` and the runtime publish door for a flow all refuse it. + +import { resolveFlowTriggerKind } from '@objectstack/spec/automation'; +import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared'; + +import { walkFlowNodes, flowNodeLabel } from './flow-walk.js'; +import { recordsOf } from './object-graph.js'; + +export interface NotifyEmailFieldRecipientFinding { + /** Every verdict here gates — see the module note. */ + severity: 'error'; + rule: string; + /** Human-readable location, e.g. `flow "application_received" › node "Tell the applicant"`. */ + where: string; + /** Config path, e.g. `flows[0].nodes[1].config.recipients.emailField`. */ + path: string; + message: string; + hint: string; +} + +// Rule ids (registry entries). +/** The named field is not declared on the trigger object. */ +export const NOTIFY_EMAIL_FIELD_UNKNOWN = 'notify-email-field-unknown'; +/** The named field is declared, and is not `email`-typed. */ +export const NOTIFY_EMAIL_FIELD_NOT_EMAIL = 'notify-email-field-not-email'; +/** The flow hands its run no triggering record of a known object. */ +export const NOTIFY_EMAIL_FIELD_NO_TRIGGER_RECORD = 'notify-email-field-no-trigger-record'; + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +/** The field type the form requires. */ +const EMAIL_FIELD_TYPE = 'email'; + +/** `fieldName -> type` for every object the stack declares (declared fields only). */ +function indexFieldTypes(stack: AnyRec): Map> { + const out = new Map>(); + for (const obj of recordsOf(stack.objects)) { + if (typeof obj.name !== 'string' || !obj.name) continue; + const types = new Map(); + for (const f of recordsOf(obj.fields)) { + if (typeof f.name === 'string' && f.name) types.set(f.name, typeof f.type === 'string' ? f.type : ''); + } + out.set(obj.name, types); + } + return out; +} + +/** The object whose record the run is handed as `record`, or why there is none. */ +function triggerRecordObjectOf(flow: AnyRec): { object: string } | { reason: string } { + const kind = resolveFlowTriggerKind(flow); + const start = recordsOf(flow.nodes).find((n) => n.type === 'start'); + const config = isRec(start?.config) ? start.config : {}; + if (kind === 'record_change') { + const object = typeof config.objectName === 'string' && config.objectName ? config.objectName : undefined; + return object + ? { object } + : { reason: 'its record trigger names no object (start `config.objectName`), so it fires on a write to any object' }; + } + if (kind === 'time_relative') { + const sweep = isRec(config.timeRelative) ? config.timeRelative : {}; + const object = typeof sweep.object === 'string' && sweep.object ? sweep.object : undefined; + return object + ? { object } + : { reason: 'its time-relative sweep names no object (start `config.timeRelative.object`)' }; + } + if (kind === 'api') return { reason: 'it is triggered by an inbound api call, which hands in the request body, not a record' }; + if (kind === 'schedule') return { reason: 'it runs on a schedule, which hands the run no record' }; + return { reason: 'it has no record trigger (start `config.triggerType: \'record-…\'`) or time-relative sweep' }; +} + +/** + * Validate every `notify` node's email-field recipient against the trigger + * object's declared fields. Pure `(stack) => Finding[]` (ADR-0019); safe on + * pre- or post-parse stacks. + */ +export function validateNotifyEmailFieldRecipients(stack: AnyRec): NotifyEmailFieldRecipientFinding[] { + const findings: NotifyEmailFieldRecipientFinding[] = []; + if (!isRec(stack)) return findings; + const flows = recordsOf(stack.flows); + if (flows.length === 0) return findings; + + // Built lazily: a stack with no email-field recipient never pays it. + let fieldTypes: Map> | null = null; + + flows.forEach((flow, flowIndex) => { + const flowName = typeof flow.name === 'string' && flow.name ? flow.name : `#${flowIndex}`; + // Every node, nested regions included — a gating rule that stops at the top + // level stops gating the moment the node is wrapped in a try_catch (#4380). + walkFlowNodes(flow, `flows[${flowIndex}]`).forEach(({ node, path: nodePath, regionTrail }, walkIndex) => { + if (node.type !== 'notify' || !isRec(node.config)) return; + const recipients = node.config.recipients; + if (!isRec(recipients) || typeof recipients.emailField !== 'string') return; + const field = recipients.emailField; + + const nodeName = flowNodeLabel(node, walkIndex); + const where = `flow "${flowName}" › ${regionTrail ? `${regionTrail} › ` : ''}node "${nodeName}"`; + const path = `${nodePath}.config.recipients.emailField`; + const lead = `notify recipient \`{ emailField: '${field}' }\` names a field of the record that triggered the flow`; + + const trigger = triggerRecordObjectOf(flow); + if ('reason' in trigger) { + findings.push({ + severity: 'error', + rule: NOTIFY_EMAIL_FIELD_NO_TRIGGER_RECORD, + where, + path, + message: `${lead}, and this flow has no triggering record of a known object: ${trigger.reason}.`, + hint: + 'Use an email-field recipient only in a flow triggered by a record of one object (a record trigger or a ' + + 'time-relative sweep that names it), or address a user instead.', + }); + return; + } + + fieldTypes ??= indexFieldTypes(stack); + const declared = fieldTypes.get(trigger.object); + // Another package's object, or one whose fields are resolved at runtime: + // a field map this pass cannot see is not judged (module note). + if (!declared || declared.size === 0) return; + + const type = declared.get(field); + if (type === undefined) { + const emailFields = [...declared].filter(([, t]) => t === EMAIL_FIELD_TYPE).map(([name]) => name); + const suggestion = formatSuggestion(findClosestMatches(field, emailFields)); + findings.push({ + severity: 'error', + rule: NOTIFY_EMAIL_FIELD_UNKNOWN, + where, + path, + message: `${lead}, and object '${trigger.object}' declares no field '${field}', so the node has no address to send to.`, + hint: emailFieldHint(trigger.object, suggestion, emailFields), + }); + return; + } + if (type !== EMAIL_FIELD_TYPE) { + const emailFields = [...declared].filter(([, t]) => t === EMAIL_FIELD_TYPE).map(([name]) => name); + findings.push({ + severity: 'error', + rule: NOTIFY_EMAIL_FIELD_NOT_EMAIL, + where, + path, + message: + `${lead}, and '${trigger.object}.${field}' is typed '${type || '(none)'}', not \`email\`: only a field ` + + 'the data model types as `email` holds a checked address.', + hint: emailFieldHint(trigger.object, '', emailFields), + }); + } + }); + }); + + return findings; +} + +/** The fix: name an `email`-typed field of the trigger object, or declare one. */ +function emailFieldHint(object: string, suggestion: string, emailFields: readonly string[]): string { + const name = emailFields.length > 0 + ? `Name one of its \`email\` fields (${emailFields.map((f) => `'${f}'`).join(', ')})` + : `Declare an \`email\` field on '${object}' that holds the address, and name it`; + return `${suggestion ? `${suggestion} ` : ''}${name}.`; +} From 954164ade09c37d1a3e2b2ca4eca6c4d90e7603d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:06:20 +0000 Subject: [PATCH 3/6] chore: changeset and suite wiring for the notify email-field recipient Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude --- .../25610-notify-email-field-recipient.md | 29 +++++++++++++++++++ .../spec/authorable-surface/automation.json | 1 + .../spec/json-schema.manifest/automation.json | 1 + 3 files changed, 31 insertions(+) create mode 100644 .changeset/25610-notify-email-field-recipient.md diff --git a/.changeset/25610-notify-email-field-recipient.md b/.changeset/25610-notify-email-field-recipient.md new file mode 100644 index 00000000000..2e8f4685d2a --- /dev/null +++ b/.changeset/25610-notify-email-field-recipient.md @@ -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 diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index b9ed1d8d28f..afeece8aab1 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -274,6 +274,7 @@ "automation/NotifyConfig:templateData", "automation/NotifyConfig:title", "automation/NotifyConfig:topic", + "automation/NotifyEmailFieldRecipient:emailField", "automation/ParallelBranch:edges", "automation/ParallelBranch:name", "automation/ParallelBranch:nodes", diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 7d062f14c18..061c47cfd03 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -55,6 +55,7 @@ "automation/MapConfig", "automation/NodeExecutorDescriptor", "automation/NotifyConfig", + "automation/NotifyEmailFieldRecipient", "automation/ParallelBranch", "automation/ParallelConfig", "automation/RetryPolicy", From 8934b74b161ce828438f52d81955f1c4ddb862e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:11:41 +0000 Subject: [PATCH 4/6] chore(spec): regenerate artefacts for NotifyEmailFieldRecipientSchema Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude --- .../references/automation/io-node-config.mdx | 29 ++++++++++++++++--- content/docs/references/index.mdx | 10 +++---- .../automation.md | 10 +++---- packages/spec/api-surface/automation.json | 2 ++ packages/spec/declaration-map/automation.json | 2 ++ packages/spec/export-origins/automation.json | 2 ++ 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index 699a93f9ccd..e877c00afb5 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -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/. */} @@ -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); @@ -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. | @@ -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` | 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 | + --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index bf472da6fc1..b41e3eda667 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -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/. */} @@ -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. | @@ -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 | --- @@ -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. @@ -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` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md index b0a0d783611..91417373d67 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md @@ -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 @@ -38,12 +38,12 @@ 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 @@ -51,7 +51,7 @@ 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 | |---|---|---| @@ -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 | |---|---| diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index d43954f8997..6d076dedef2 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -204,6 +204,8 @@ "NotifyConfig (type)", "NotifyConfigParsed (type)", "NotifyConfigSchema (const)", + "NotifyEmailFieldRecipient (type)", + "NotifyEmailFieldRecipientSchema (const)", "ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT (const)", "PARALLEL_NODE_TYPE (const)", diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index 8ed864f0014..b331ad941ca 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -103,6 +103,8 @@ "NodeExecutorDescriptorSchema": "automation/NodeExecutorDescriptor", "NotifyConfig": "automation/NotifyConfig", "NotifyConfigSchema": "automation/NotifyConfig", + "NotifyEmailFieldRecipient": "automation/NotifyEmailFieldRecipient", + "NotifyEmailFieldRecipientSchema": "automation/NotifyEmailFieldRecipient", "ParallelBranch": "automation/ParallelBranch", "ParallelBranchSchema": "automation/ParallelBranch", "ParallelConfig": "automation/ParallelConfig", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index b6938167814..b31e99649ef 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -199,6 +199,8 @@ "NotifyConfig": "src/automation/io-node-config.zod.ts#NotifyConfig (type)", "NotifyConfigParsed": "src/automation/io-node-config.zod.ts#NotifyConfigParsed (type)", "NotifyConfigSchema": "src/automation/io-node-config.zod.ts#NotifyConfigSchema (const)", + "NotifyEmailFieldRecipient": "src/automation/io-node-config.zod.ts#NotifyEmailFieldRecipient (type)", + "NotifyEmailFieldRecipientSchema": "src/automation/io-node-config.zod.ts#NotifyEmailFieldRecipientSchema (const)", "ORG_MEMBERSHIP_LEVELS": "src/automation/approval.zod.ts#ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT": "src/automation/bpmn-mapping.ts#OS_CONSTRUCT_EXT (const)", "PARALLEL_NODE_TYPE": "src/automation/control-flow.zod.ts#PARALLEL_NODE_TYPE (const)", From 2a26b7e90b7512b51a400ebcae975b4b821a8a0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:51:16 +0000 Subject: [PATCH 5/6] fix(spec): name the parsed state of NotifyEmailFieldRecipientSchema (ADR-0122) Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude --- packages/spec/src/automation/io-node-config.zod.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 9226d76bb9f..f0cdbbfe67a 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -295,7 +295,8 @@ export const NotifyEmailFieldRecipientSchema = strictObject({ + 'the field is named, never an address or an expression', }); -export type NotifyEmailFieldRecipient = z.infer; +export type NotifyEmailFieldRecipient = z.input; +export type NotifyEmailFieldRecipientParsed = z.infer; /** True when `value` is written as an email-field recipient — a plain object with an `emailField` key. */ function isEmailFieldRecipientShaped(value: unknown): value is Record { From 433f7f2005c1d37a3eb8fea3569d9b874103aee4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 12:01:25 +0000 Subject: [PATCH 6/6] chore(spec): regenerate api-surface and export-origins for NotifyEmailFieldRecipientParsed Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude --- packages/spec/api-surface/automation.json | 1 + packages/spec/export-origins/automation.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 6d076dedef2..48fb6a35e89 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -205,6 +205,7 @@ "NotifyConfigParsed (type)", "NotifyConfigSchema (const)", "NotifyEmailFieldRecipient (type)", + "NotifyEmailFieldRecipientParsed (type)", "NotifyEmailFieldRecipientSchema (const)", "ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT (const)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index b31e99649ef..027c2491c3a 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -200,6 +200,7 @@ "NotifyConfigParsed": "src/automation/io-node-config.zod.ts#NotifyConfigParsed (type)", "NotifyConfigSchema": "src/automation/io-node-config.zod.ts#NotifyConfigSchema (const)", "NotifyEmailFieldRecipient": "src/automation/io-node-config.zod.ts#NotifyEmailFieldRecipient (type)", + "NotifyEmailFieldRecipientParsed": "src/automation/io-node-config.zod.ts#NotifyEmailFieldRecipientParsed (type)", "NotifyEmailFieldRecipientSchema": "src/automation/io-node-config.zod.ts#NotifyEmailFieldRecipientSchema (const)", "ORG_MEMBERSHIP_LEVELS": "src/automation/approval.zod.ts#ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT": "src/automation/bpmn-mapping.ts#OS_CONSTRUCT_EXT (const)",