Repository navigation
feat(spec,lint): a notify recipient form { emailField } — the address in an email-typed field of the triggering record - #25645
Conversation
…eld } (#25610) Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude <noreply@anthropic.com>
…-typed trigger field Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude <noreply@anthropic.com>
…tify-email-field-recipient
…ADR-0122) Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude <noreply@anthropic.com>
…lFieldRecipientParsed Claude-Session: https://claude.ai/code/session_01QSfgcYNXwYoApr3y36B1nJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 144 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2c2fd40673cc2c709e008a09b4adaf84d9b4689e && git checkout 2c2fd40673cc2c709e008a09b4adaf84d9b4689e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca80f05ec5c6aab22f31553cb5f8737a17450563 433f7f2005c1d37a3eb8fea3569d9b874103aee4 && git checkout -B drift-repro ca80f05ec5c6aab22f31553cb5f8737a17450563 && git merge --no-ff 433f7f2005c1d37a3eb8fea3569d9b874103aee4
node scripts/docs-audit/affected-docs.mjs --json ca80f05ec5c6aab22f31553cb5f8737a17450563
|
席位核对ACCEPT · #25610 ·
Generated by Claude Code |
|
Claim: cross-account contract review of this PR at head ⛔ Not a claim on the card and no label change now. Under the cross-account review patrol ( Generated by Claude Code |
Contract reviewServed-tier: Cross-account review: the PR was implemented under ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Landing held · Contract review PASS It is held for one product question, which the cross-lane note Generated by Claude Code |
|
Hold lifted, landing · The maintainer's word in the seat's session, verbatim: 「你按照 pm-dispatch 技能审核过的pr你都可以落地」. This PR is reviewed to the skill's terms: ACCEPT Generated by Claude Code |
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.recipientstakes a fourth union arm,NotifyEmailFieldRecipientSchema(exported, with its typeNotifyEmailFieldRecipient):{ emailField: 'applicant_email' }, a closed object whose one key holds a field's machine name (FieldSchema.namegrammar). 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;emailFieldholding an envelope is refused). Two more constraints rideNotifyConfigSchema:recipientsvalue, never a list entry (refused with its own sentence);emailas its only channel (channels: 'email'or['email']); nochannels, or any other channel, is refused atchannels. An address no user holds has no inbox.@objectstack/lint— the publish-time refusal.validateNotifyEmailFieldRecipients, a reference-integrity suite member (soos validate,os lint,os buildand the runtime publish door for aflowwrite all run it), resolves the named field against the trigger object's declared fields. Three gating verdicts:notify-email-field-unknownnotify-email-field-not-emailemail-typednotify-email-field-no-trigger-recordThe triggering record's object is the start node's
objectNamefor a record trigger andtimeRelative.objectfor 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) typesrecipients(was:547) as string | list | CEL envelope. Holds.packages/spec/liveness/flow.json": disproved.FlowNodeSchema.configisz.record(z.string(), z.unknown())(flow.zod.ts:630), andflow.jsonholdsnodes.configas a singleliveleaf 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, therecipients.describe()(what authors read in Studio and the reference docs) and the changeset both say delivery is not live until the messaging half lands.notify-node.ts#resolveRecipientshands a non-envelope object throughresolveValueSlotunchanged 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 oneorigin/mainmerge (8040d181, which brought #25617). On the final head433f7f2005(merge plus the ADR-0122NotifyEmailFieldRecipientParsedalias) I re-rantsc --noEmitfor 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 newnotify-email-field-recipient.test.ts(17 tests) and the updated JSON-Schema arm pin inio-node-config.test.ts.pnpm --filter @objectstack/lint test: 159 files / 7243 passed, 5 skipped. Includes the newvalidate-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.pnpm --filter @objectstack/service-automation typecheckandtest(199 files / 2628 tests) green. It parsesNotifyConfigSchemaat execute time.scripts/ablation-replace.mjs, which checks the anchor hit and that restore leftgit diff HEADempty):if (type !== EMAIL_FIELD_TYPE)toif (false): 3 tests red (not-email, nested region, publish door). Restored, blob == HEAD.if (channels.length !== 1 || channels[0] !== 'email')toif (false): 4 tests red. Restored, blob == HEAD.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon433f7f2005: 113 commands, every one exit 0. Each exit code was captured to a file before any pipe.--ranreconciliation: "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)".8040d181, had three prerequisite refusals (exit 3):check-plugin-teardown-shape --self-test(shallow clone),check:skill-examples(client packages not built) andcheck:dual-build-cjs-loads(no dist). It also had one real finding:check:spec-parsed-alias(ADR-0122: a bare alias readz.infer). I fixed the finding in2a26b7e90b, unshallowed the clone, builtclientandclient-react, and re-ran the whole list. All green.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
recipientsvalue, never a list entry; (2) the node must nameemailas its only channel. Both are narrow on purpose. Widening either later is non-breaking. Narrowing after release would be breaking.content/docs/automation/flows.mdxrecipients paragraph is not touched, so it does not advertise a form the runtime does not deliver yet. It belongs with service-messaging: a flownotifynode can only address system users, so an applicant who came in through a public form never hears "received" or "rejected" #25571.service-messaging's email channel as a literal address (email-channel.ts#resolveRecipient,EMAIL_SHAPE). Therecipientsdocblock 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