Repository navigation
feat(service-automation)!: notify mails an email-field recipient, limited per address, and refuses a computed address no user holds - #25721
Conversation
…and refuses a computed non-user address
…ted per address, and refuses a computed non-user address Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude <noreply@anthropic.com>
…ess refusal Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude <noreply@anthropic.com>
…through the engine predicate, pinned Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check15 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 6 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 6aadf75a08a73fc18e3bab47808273deb593d90a && git checkout 6aadf75a08a73fc18e3bab47808273deb593d90a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f3fea0a03df3f4859dd389899df5a4ea036d1545 d4fd5968a5a9002e3aa9c35ceadb1849a9e13f47 && git checkout -B drift-repro f3fea0a03df3f4859dd389899df5a4ea036d1545 && git merge --no-ff d4fd5968a5a9002e3aa9c35ceadb1849a9e13f47
node scripts/docs-audit/affected-docs.mjs --json f3fea0a03df3f4859dd389899df5a4ea036d1545 |
… refuse combinators in the email-field double Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude <noreply@anthropic.com>
…narrowed Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude <noreply@anthropic.com>
…fy node mails The #25647 screen ran for every notify node, so an inbox-only node whose recipient expression evaluates to the owner's email (app-showcase's approval and task-done notices, `record.owner` holding an email) failed when the run had no readable user directory: Test Core (4/6) red in example-showcase. Only the `email` channel sends to the address itself; `inbox` (the messaging default) mails nobody. The screen now runs when `channels` include `email`, and the docs, Studio description and changeset say so. E5/E6 pin both sides. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | draft, base main, Fixes #25571 and Fixes #25647, one changeset (service-automation: minor, title !). Body patched by the seat this act through the relay (read-back identical): the Clause-②: line now equals the changeset's line verbatim (no (narrowing: …), the arm Check Changeset requires beside Metadata-protocol: narrowed), the title carries !, and the stale readings, producer count and #25647 scope were brought to this head. |
| Files (8, +713/−13) | notify-node.ts, its new integration test, flows.mdx, the changeset: the claim's surface. Accepted deviations, each forced by a gate or a dependency cycle, none a new graph edge: service-automation/package.json (@objectstack/plugin-auth dev → runtime dependency, for the repo's one fixed-window counter, which service-messaging cannot import because plugin-auth depends on it), its pnpm-lock.yaml importer hunk, vitest.config.ts (source alias check:test-source-alias requires), scripts/engine-double-contract.pinned.json (pin for the new fake engine). No packages/spec/**, no engine.ts, no service-messaging/**. Not governed. |
| Clause-② deviation | the claim said Clause-②: no; the dev measured the narrowing arm and wrote it. Accepted: the claim's own Metadata-protocol: narrowed line already named the narrowing. |
| open question 1 | ruling 6386629237 B does not name channels; the head screens computed addresses only where channels include email (inbox mails nobody; app-showcase's inbox-only owner notices compute addresses and stay unchanged, which keeps the ruling's "B 不拒绝任何现有配置" true). Put to the maintainer as a confirmation; the seat lands on this scope meanwhile, and the other answer is an additive follow-up (screen every channel plus app-showcase sys_user seeds). |
| open question 2 | 5 sends per address per hour: accepted. Ruling 6377887204 fixes "rate-limited per address" with no figure; exported constants keep it one place to change. |
| Gates | dev 118 derived, 118 exit 0; dogfood battery 2,520 passed, 0 failed; six ablations each red. CI at this act: 36 success, 1 failure (Check Changeset, the body line, re-read on edit), all Test Core shards green. |
| Out of scope | the email field validator admits separators and selector prefixes in the local part (class c, record-validator.ts): carrier none yet, the seat files it after landing. The stale spec describe sentence is #25696's. |
| Landing | owes the narrowing's CONTRACT_REVIEW_TIER record on this head (needs:contract-review set), then ready + auto-merge through the relay once every check is green. |
Generated by Claude Code
Contract reviewServed-tier: Written 2026-10-25T02:26Z · face: narrowing ( ① Derived judgmentsEach accept-set or public-surface change the diff implies, named right or wrong.
② Semver level
③ Boundary flagsDev report: comment 6392322238 on #25571. Seat ACCEPT: comment 6392365089 on the PR. PR author login
Implemented-by: VERDICT: FAIL One finding blocks (F1); F2–F5 are flags for the seat, none a blocker on its own. A re-review on the next head needs only F1's fix and pin re-read against this record. |
…path The messaging resolver hands the remainder of a `user:` value on as the recipient id with no lookup, and the email channel sends to any address-shaped id, so a computed `user:stranger@x.y` (or `user:user:stranger@x.y`) reached the mail unscreened. The screen now takes the address the email channel would send to, bare or after one `user:`, and resolves or refuses it like a bare address; `owner_of:` reads a stored owner field and stays unscreened. E7/E8 pin both sides. Docs and changeset now declare that the per-address limit is best-effort on a counter fault and that the email-field address grammar is ASCII only (the refusal text says so), and name the new service-automation to plugin-auth workspace edge. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB
Contract reviewServed-tier: Written 2026-10-25T03:02Z · face: narrowing ( ① Derived judgments
② Semver level
③ Boundary flagsDev report: 6392322238 on #25571. Seat ACCEPT: 6392365089 (at
Implemented-by: VERDICT: PASS F1 is fixed and pinned (E7/E8); F3 and F5 are declared where the author reads them; the dependency edge is described truly. F2 and F4 are flags for the seat, neither a blocker. Generated by Claude Code |
Requested by Leehom · project thread
Fixes #25571
Fixes #25647
Clause-②: no (narrowing: a recipient that the value expression of a
notifynode whosechannelsincludeemailevaluates to an email address no user holds now fails the node at run time instead of being mailed)Metadata-protocol: narrowed
What changes
A
notifynode can now mail a person who is not a user.recipients: { emailField: 'applicant_email' }withchannels: 'email'(the form PR #25645 declared in the spec, which until now failed the run at the node) mails the address in thatemail-typed field of the record that triggered the flow. Ruling 6377887204 (letter B): "recipient = a record field of type email only; templated; rate-limited per address; logged".resolveEmailFieldAddressinpackages/services/service-automation/src/builtin/notify-node.tsreads the field off the STORED trigger row by id (system context, projectionid+ the field), never off therecordsnapshot the run carries. Every way there is no address fails the node and says which: no trigger record, no data engine, a row that is not stored, an empty field, a value that is not one address. The failure names the field and the record, never the value, because a visitor typed it.emailfield type's write check is^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$(packages/objectql/src/validation/record-validator.ts). It admits a local part holding a comma, semicolon, colon, angle brackets or quotes, androle:x@y.zreads as an audience selector to the messaging resolver.isOneAddressrequires dot-separated RFC 5322 atoms and hostname labels, linear scans only. A list, a display name, a separator, a line break and an audience prefix each fail the node (pinned case by case). The grammar is plain ASCII on purpose: a non-ASCII character, a quoted local part or an IP-literal domain the email field type stored is refused, and the refusal says "does not hold one plain ASCII email address"; flows.mdx and the changeset declare it.messaging.emitas the audience withchannels: ['email']. The existing chain does the rest: the recipient resolver keeps an address no user holds verbatim, the email channel sends a literal address as is, and the send is recorded as asys_notificationevent plus asys_notification_deliveryrow whose recipient is the address. Title and message render as for any notify. No messaging-side change was needed, sopackages/services/service-messaging/**is untouched.EMAIL_FIELD_SENDS_PER_WINDOW(5) sends perEMAIL_FIELD_SEND_WINDOW_SECONDS(1 hour) to one address, lowercased, across every flow. Measured first: service-messaging has no limiter. The repo's one fixed-window counter isincrementFixedWindow/createLazyCounterStorein@objectstack/plugin-auth/rate-limit-storage, counted in the kernelcacheservice and resolved at count time, which@objectstack/service-smsalready uses. service-messaging cannot import it (plugin-auth depends on service-messaging, a cycle), so the limit lives in the node, which is also the only place that knows the address came from an email field.@objectstack/plugin-authmoves from a dev dependency to a dependency of service-automation (a new workspace edge, service-automation → plugin-auth; no cycle: neither plugin-auth nor service-messaging or rest depends on service-automation). A refused send fails the node before anything is written. A counter fault admits, said once atwarn, matching the SMS quota's fail-open. The limit is declared best-effort in flows.mdx and the changeset.#25647, ruling 6386629237 letter B: a computed address must be a user's
Verbatim: "
notify收件人若由表达式(CEL 信封)求值得到一个不属于任何用户的邮箱地址,节点失败并给出定位提示指向{ emailField };求值得到某个用户的地址仍解析为该用户。元数据里写死的地址是作者常量,照常发出。"channelsincludeemail. Only the email channel delivers to the raw address; a node oninboxalone (the messaging default) mails nobody, so its recipients pass to messaging unscreened, as before. Whether this reading of the ruling stands is put to the maintainer as a confirmation (open question 1 of the os-dev-report6392322238); the seat lands on it meanwhile, and screening every channel would be an additive follow-up.resolveRecipientsnow marks each recipient an envelope evaluated to that the messaging resolver would read as an address (the address the email channel would send to: a bare address, or the remainder after oneuser:, which the resolver hands on with no lookup;role:andteam:yield user ids).screenComputedAddresseslooks each one up the way the resolver does (sys_userbyemail, the id projected): a user holds it, the recipient becomes that user's id; no user holds it, the node fails, located (recipients[0] evaluated to an email address — source: …) and pointing torecipients: { emailField: 'FIELD' }. No data engine, or a failing lookup, fails the node: an address that could not be checked is not sent.owner_of:is not screened: its address comes from a stored owner field written through record writes, not from a string the expression builds (seat ruling on F1).recipients: ['ops@example.com']) never reaches the check and is sent as before (pinned).git grep -nE "recipients\s*:\s*\[?\s*\{\s*dialect" origin/main -- examplesat47af3ddgives 17 envelopes. The app-showcase ones overrecord.owner/ownerIddo evaluate to addresses (that field holds the owner's email), but those nodes areinboxonly, and nonotifynode inexamples/**names theemailchannel, so no in-repo flow starts failing. HotCRM ata4b3560: 0 hits.Docs
content/docs/automation/flows.mdxgains the recipients paragraph PR #25645 left to this card: the email-field form with an example, what fails, the limit, the log, and the computed-versus-written-out rule.Left to #25696 (the spec card)
recipientsdescribe ofpackages/spec/src/automation/io-node-config.zod.ts(and the generatedcontent/docs/references/automation/io-node-config.mdxrow) is now false and is spec(automation): the notify recipients describe names a written-out address as an author constant, and drops the bare "an email address" audience selector — the spec half of ruling B on #25647 #25696's to remove.packages/spec/**orpackages/services/service-automation/src/engine.ts.Tests
New
packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts, through the realMessagingService(outbox-backed),NotificationDispatcherand email channel:successdelivery row record it.{ emailField }; computed user address resolves to the user id; written-out address sent as written; no data engine, computed address not sent.inbox-only node, and a node with nochannels, pass a computed address to messaging unscreened. E6:channels: ['inbox', 'email']is screened.user:ADDRESSanduser:user:ADDRESSno user holds underchannels: emailfail the node, nothing enqueued or mailed. E8: a computeduser:plus a user's address resolves to that user.Readings at
d4fd5968: the file 23/23; service-automation 202/202 files (2,659 tests); example-showcase 39/39; plugin-approvals 96/96;pnpm --filter @objectstack/service-automation typecheckexit 0; eslint over the package exit 0; dogfood battery 295 files / 2,520 tests passed, 0 failed.Ablations (each through
scripts/ablation-replace.mjs, mutation proven on disk, restored to the HEAD blob withgit diff HEADempty):if (false))isOneAddress(type check only)context.record[field] ?? row[field])user:branch (old prefix refusal)Acceptance notes
dispatch-gates --commandsunion at the final head, with every exit code, is in the os-dev-report comment on 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; CI's check-runs are the authority for the full farm.check:dual-build-cjs-loadsandcheck:skill-examplesexit 0 after a full build (at450832fbandd4fd5968).Metadata-protocol: narrowed) owes one contract-review-tier review before the queue.Generated by Claude Code