Repository navigation
Commit 6021b5e
feat(spec,lint): a notify recipient form
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>{ emailField } — the address in an email-typed field of the triggering record (#25645)1 parent b2909fd commit 6021b5e
17 files changed
Lines changed: 682 additions & 22 deletions
File tree
- .changeset
- content/docs/references
- automation
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- lint/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src/automation
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | | - | |
67 | | - | |
| 66 | + | |
| 67 | + | |
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
| 98 | + | |
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
| |||
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
112 | 133 | | |
113 | 134 | | |
114 | 135 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
| 25 | + | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
109 | | - | |
| 109 | + | |
110 | 110 | | |
111 | 111 | | |
112 | 112 | | |
| |||
119 | 119 | | |
120 | 120 | | |
121 | 121 | | |
122 | | - | |
| 122 | + | |
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | | - | |
| 54 | + | |
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
| |||
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
63 | | - | |
| 63 | + | |
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
855 | 855 | | |
856 | 856 | | |
857 | 857 | | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
858 | 868 | | |
859 | 869 | | |
860 | 870 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
73 | 77 | | |
74 | 78 | | |
75 | 79 | | |
| |||
348 | 352 | | |
349 | 353 | | |
350 | 354 | | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
351 | 363 | | |
352 | 364 | | |
353 | 365 | | |
| |||
379 | 391 | | |
380 | 392 | | |
381 | 393 | | |
| 394 | + | |
382 | 395 | | |
383 | 396 | | |
384 | 397 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| 126 | + | |
126 | 127 | | |
127 | 128 | | |
128 | 129 | | |
| |||
589 | 590 | | |
590 | 591 | | |
591 | 592 | | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
592 | 599 | | |
593 | 600 | | |
594 | 601 | | |
| |||
0 commit comments