Skip to content

feat(service-automation)!: notify mails an email-field recipient, limited per address, and refuses a computed address no user holds - #25721

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-e1l64i
Oct 25, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-e1l64i

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #25571
Fixes #25647

Clause-②: no (narrowing: a recipient that the value expression of a notify node whose channels include email evaluates to an email address no user holds now fails the node at run time instead of being mailed)
Metadata-protocol: narrowed

What changes

A notify node can now mail a person who is not a user. recipients: { emailField: 'applicant_email' } with channels: 'email' (the form PR #25645 declared in the spec, which until now failed the run at the node) mails the address in that email-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".

  • Where the address comes from. resolveEmailFieldAddress in packages/services/service-automation/src/builtin/notify-node.ts reads the field off the STORED trigger row by id (system context, projection id + the field), never off the record snapshot 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.
  • One address, nothing else. The email field 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, and role:x@y.z reads as an audience selector to the messaging resolver. isOneAddress requires 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.
  • Templated and logged. The address goes to messaging.emit as the audience with channels: ['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 a sys_notification event plus a sys_notification_delivery row whose recipient is the address. Title and message render as for any notify. No messaging-side change was needed, so packages/services/service-messaging/** is untouched.
  • Rate-limited per address. At most EMAIL_FIELD_SENDS_PER_WINDOW (5) sends per EMAIL_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 is incrementFixedWindow / createLazyCounterStore in @objectstack/plugin-auth/rate-limit-storage, counted in the kernel cache service and resolved at count time, which @objectstack/service-sms already 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-auth moves 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 at warn, 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 };求值得到某个用户的地址仍解析为该用户。元数据里写死的地址是作者常量,照常发出。"

  • Scope: nodes whose channels include email. Only the email channel delivers to the raw address; a node on inbox alone (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-report 6392322238); the seat lands on it meanwhile, and screening every channel would be an additive follow-up.
  • resolveRecipients now 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 one user:, which the resolver hands on with no lookup; role: and team: yield user ids). screenComputedAddresses looks each one up the way the resolver does (sys_user by email, 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 to recipients: { 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).
  • A written-out address (recipients: ['ops@example.com']) never reaches the check and is sent as before (pinned).
  • In-repo producers, re-checked per the card: git grep -nE "recipients\s*:\s*\[?\s*\{\s*dialect" origin/main -- examples at 47af3dd gives 17 envelopes. The app-showcase ones over record.owner / ownerId do evaluate to addresses (that field holds the owner's email), but those nodes are inbox only, and no notify node in examples/** names the email channel, so no in-repo flow starts failing. HotCRM at a4b3560: 0 hits.

Docs

content/docs/automation/flows.mdx gains 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)

Tests

New packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts, through the real MessagingService (outbox-backed), NotificationDispatcher and email channel:

  • A: the stored address is mailed (a different address in the run's snapshot is ignored), the title renders, and the event row plus a success delivery row record it.
  • B: eight values that are not one address each fail the node, write nothing, and the failure does not echo the value.
  • C: no trigger record, a row not stored, an empty field.
  • D: 5 sends admitted, the 6th to the same mailbox in other case refused with nothing written, another address unaffected.
  • E1–E4: computed non-user address refused naming { emailField }; computed user address resolves to the user id; written-out address sent as written; no data engine, computed address not sent.
  • E5 (×2): an inbox-only node, and a node with no channels, pass a computed address to messaging unscreened. E6: channels: ['inbox', 'email'] is screened.
  • E7 (×2): a computed user:ADDRESS and user:user:ADDRESS no user holds under channels: email fail the node, nothing enqueued or mailed. E8: a computed user: 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 typecheck exit 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 with git diff HEAD empty):

Put back Red
the limit check (if (false)) D
isOneAddress (type check only) B, 7 of 8 (the number value is still refused by the type check, so its pin stays green)
the computed-address screen (return recipients unscreened) E1, E4
the snapshot read (context.record[field] ?? row[field]) A
the channel gate forced true E5 ×2
the channel gate forced false E1, E4, E6
the user: branch (old prefix refusal) E7 ×2, E8

Acceptance notes


Generated by Claude Code

…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>
…through the engine predicate, pinned

Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 25, 2026
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

15 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-automation/package.json, packages/services/service-automation/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/services/service-automation/package.json, packages/services/service-automation/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 43 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f3fea0a03df3f4859dd389899df5a4ea036d1545 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6aadf75a08a73fc18e3bab47808273deb593d90a — the merge of head d4fd5968a5a9002e3aa9c35ceadb1849a9e13f47 into base f3fea0a03df3f4859dd389899df5a4ea036d1545, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

… refuse combinators in the email-field double

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
@objectstack-fleet objectstack-fleet Bot changed the title feat(service-automation): notify mails an email-field recipient, limited per address, and refuses a computed address no user holds feat(service-automation)!: notify mails an email-field recipient, limited per address, and refuses a computed address no user holds Oct 25, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-25T02:16Z

Head 450832fb52, report 6392322238 on #25571, claims 6390685626 (#25571) and 6390694831 (#25647) (one branch claude/project-thread-e1l64i, ride ruled by 6386629237).

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 450832fb5256d3072b4f23ae33d219da77cdc791
Local-runs: none
Scope: packages/services/service-automation/src/builtin/notify-node.ts, packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts, packages/services/service-automation/package.json, packages/services/service-automation/vitest.config.ts, pnpm-lock.yaml, scripts/engine-double-contract.pinned.json + packages/services/service-messaging/src/recipient-resolver.ts, packages/services/service-messaging/src/email-channel.ts, packages/services/service-messaging/src/messaging-service.ts (emit fan-out, default channel), packages/plugins/plugin-auth/src/rate-limit-storage.ts, packages/plugins/plugin-auth/package.json (exports, dependency direction), packages/spec/src/automation/io-node-config.zod.ts (NotifyRecipientsSlotSchema, NotifyEmailFieldRecipientSchema, the channels superRefine), packages/services/service-automation/src/builtin/index.ts (what the package re-exports). The prose faces (content/docs/automation/flows.mdx, .changeset/25571-notify-email-field-delivery.md) were read as the declarations the code is judged against.

Written 2026-10-25T02:26Z · face: narrowing (Clause-②: no (narrowing: …) on the PR body and the changeset) · inputs: cards #25571 and #25647 with every comment, rulings 6377842908 / 6377887204 / 6377731715 (#25496, #25444) and 6386629237 (#25647), PR #25721 body, file list and net diff against main at 47af3dde, the 59 check-runs on this head. Read-only: nothing built, run or re-run.

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong.

  1. { emailField } is delivered as the spec declares it — RIGHT, with one declaration still contradicting it. The runtime treats the form only as the whole recipients value (isEmailFieldRecipient(cfg.recipients) after parseNodeConfig with NotifyConfigSchema; a list entry never parses), and the same parse enforces channels: 'email' alone at run time (the spec's superRefine runs inside parseNodeConfig, so a node naming inbox beside it fails before any read). The address is read off the STORED trigger row by id under a system read projecting id + the field, never off the run's record snapshot (pinned, A). Templated through the ordinary payload, mailed through the unchanged email channel (EMAIL_SHAPE literal branch), and logged as a sys_notification event plus a sys_notification_delivery row (pinned, A). The field's email type is judged by lint at publish, as the spec's section note says; the runtime does not re-check the type and instead requires one address — consistent with the declaration. Ruling 6377887204's four conditions (email-typed record field only; templated; rate-limited per address; logged) are each met. ⚠ The spec describe still says "Delivery to an email-field recipient is not live yet … a run that reaches the node fails there" — false once this merges; see ③ F4.

  2. The per-address limit — RIGHT in shape, with a stated fail-open. incrementFixedWindow over createLazyCounterStore is the repository's one fixed-window counter (@objectstack/plugin-auth/rate-limit-storage, a declared subpath export); the key is the lowercased address; counted before anything is written, so a refused sixth send writes no event (pinned, D, case-insensitive, another address keeps its budget). The counter resolves the kernel cache at count time (the reference pattern AGENTS.md names). A counter get/set fault admits with one warn per process (counterFaultWarned is never reset) — the SMS quota's precedent; neither the changeset nor flows.mdx says the limit is best-effort. Flag, ③ F3.

  3. The computed-address screen — WRONG: it is bypassed by the one spelling the chain mails without any directory lookup. readsAsAddress returns false for any value with a user: / role: / team: / owner_of: prefix, so such a value is never put in computed and goes to messaging unscreened. Read in the cited code: the resolver's resolveOne strips user: and returns the remainder as the recipient id with NO sys_user lookup (recipient-resolver.ts, if (value.startsWith('user:')) return [value.slice(5)]); the outbox row's recipientId is that remainder; the email channel's resolveRecipient then takes EMAIL_SHAPE(recipient) as a literal address and sends to it (email-channel.ts, the branch E3 relies on for the written-out constant). So in a node whose channels include email, an envelope over record text that evaluates to user:stranger@outside.example — six characters a visitor can type into the same text field — mails stranger@outside.example, a user holds it or not. That is the path ruling 6386629237 B closes (「若由表达式(CEL 信封)求值得到一个不属于任何用户的邮箱地址,节点失败」, read with 6377887204's 「⛔ 不接受自由文本或表达式拼出的地址(否则就是一个对外群发口)」) and the one the PR's own docblock says is closed ("an address built from record text lets whoever typed that text choose who this flow mails"). The mirror in readsAsAddress reproduces the resolver's looksLikeEmail, but the chain reads an address in TWO places, and the second (the email channel on the resolved id) is the one the user: prefix reaches. owner_of:OBJ:ID is the same class one step removed (it mails the owner field's stored value when that value is address-shaped, which app-showcase's owner is), but the address there comes from a stored field, not from the visitor's text; role: and team: yield user_id columns and are clean. No pin covers the user: spelling (E1–E6 test bare addresses only). Finding F1, FAIL-grade: the narrowing as declared ("a recipient that a value expression evaluates to and that reads as an email address … fails the node when no user holds it") is not what the runtime enforces on the mail path.

  4. The screen's scope and the two sides it keeps — RIGHT where it applies. A computed address a user holds resolves to that user's id (E2); a written-out string never enters computed and is sent as written (E3) — ruling B's 「元数据里写死的地址是作者常量,照常发出」; no data engine or a failing lookup fails the node (E4) rather than sending unchecked — stricter than the ruling's letter in the direction the ruling points, and declared in the changeset. The channel gate (toStringList(cfg.channels).includes('email')) is sound on the pushed scope: ['inbox'] is the messaging default (requestedChannels = input.channels?.length ? input.channels : ['inbox']), and the email channel is the only one that treats an address-shaped recipient id as a destination (the SMS channel's literal branch is PHONE_SHAPE). See ③ F2 for the open confirmation.

  5. isOneAddress refuses values the email field type admits — a refusal the declarations do not say. The intent is ruled (6377887204, 「访客输入同样净化」), and the list/separator/display-name/header-break/role: cases are right and pinned (B). But the grammar is ASCII-only (^[A-Za-z0-9-]+$ per domain label; the RFC 5322 atext class per local-part atom), so an internationalised address — a non-ASCII domain label or local part, a quoted local part, an IP-literal domain — that the email validator stored is refused with "does not hold one email address", which is false for it. flows.mdx and the changeset say "exactly one mailbox, name@example.com: no list, display name or separator" and nowhere "ASCII only". Flag F5: state the restriction where the author reads it, or admit non-ASCII labels; a wrong refusal message is the smaller half.

  6. Public surface — RIGHT, Clause-②: no holds. The two new export consts (EMAIL_FIELD_SENDS_PER_WINDOW, EMAIL_FIELD_SEND_WINDOW_SECONDS) are module exports only: builtin/index.ts re-exports registerNotifyNode by name and nothing else, so the package's published surface does not widen. The changeset's "exported constants, one place to change" reads as if a consumer could import them; a nit, not a finding. The spec's accepted shape is untouched (packages/spec/** not in the diff, as claimed).

  7. Dependency move — accepted, mis-described. @objectstack/plugin-auth dev → runtime dependency of @objectstack/service-automation IS a new workspace edge (service-automation → plugin-auth → better-auth, @objectstack/rest, @objectstack/service-messaging); "no new graph edge" is true only of the hoisted runtime/cli closure. No cycle: neither plugin-auth, service-messaging nor rest depends on service-automation (read in their manifests; Validate Package Dependencies success). The cycle argument for not placing the limiter in service-messaging is correct (plugin-auth depends on service-messaging). The vitest alias and the engine-double-contract.pinned.json row are gate-forced and read as claimed.

② Semver level

  • Changeset .changeset/25571-notify-email-field-delivery.md: '@objectstack/service-automation': minor — the one released package whose published source the diff moves (docs, lockfile, vitest config and the pin ledger publish nothing). Metadata-protocol: narrowed → minor per the rule ("a value refused … declare the (narrowing) arm too"); the kind is right: an authorable config (a value envelope over a non-user address under email) that ran now fails. Clause-②: no (narrowing: …) — no is true (①.6), the (narrowing) arm present, the two lines byte-identical in the PR body and the changeset (the body was edited at 02:15:51Z to match). Title carries ! in both. Exactly one ADR-0087 marker, not-required (no-migration-prescription), with the other five categories closed on facts in its text; the gate accepted it. Correct as declared — but F1 means the changeset's BREAKING paragraph describes a refusal the runtime does not fully deliver; it stands once F1 is fixed, and needs no re-wording unless the fix changes the stated rule.
  • Check-runs on 450832fb (59): every gate-carrying job concluded; nothing pending at this read. Required contexts all success: Lint & Repo Gates, TypeScript Type Check, Test Core (and shards 1/6–6/6), Build Core, Governed Surface Queue Guard. Check Changeset: the 01:26Z run (121041186110) was failure on the body's bare Clause-②: no beside narrowed; re-read on the 02:15Z edit, the latest run (121050669250) is success at 02:17:36Z. Skipped by path filter: UI lanes, Console Pin Gate, Temporal Conformance, the sharded Dogfood matrix, both opt-in harnesses. Docs Drift Check comment: no hand-written page names the anchors; flows.mdx is the page this PR writes by hand.

③ Boundary flags

Dev report: comment 6392322238 on #25571. Seat ACCEPT: comment 6392365089 on the PR. PR author login objectstack-fleet[bot] (nothing to note). PR is draft; the landing waits on this tier's PASS record (Prime Directive #14 / Post-Task step 2).

  • F1 (FAIL) — the user: spelling bypasses the computed-address screen on the mail path (①.3). Remedy in the node: treat a computed value with the user: prefix whose remainder readsAsAddress as computed and screen the remainder (or strip the prefix before the heuristic), and pin it beside E1 (a visitor-typed user: + address under channels: 'email' fails the node, nothing enqueued). Decide owner_of: in writing (screen the resolved owner value, or state why a stored owner field is the author's, not the visitor's). The declaration lines need no change for this.
  • F2 — open question 1 (channel scope of ruling B), put to the maintainer as a confirmation. The head is sound on its pushed scope (①.4): only the email channel delivers to the raw address; inbox-only nodes mail nobody; app-showcase's inbox-only owner envelopes keep working, which keeps the ruling's 「B 不拒绝任何现有配置」 true. The other answer (screen every channel) would require: dropping the mails gate, inverting the E5 pins and keeping E6, a sys_user object plus seeded users in the showcase test kernel (examples/**, outside this claim — a follow-up card or a claim widening), and rewriting the changeset's "Who moves" paragraph, since the two app-showcase owner notices would then fail wherever the owner is not a signed-up user. Not a blocker for this head.
  • F3 — the limit is fail-open on a counter fault (①.2), and silent after the first warning for the life of the process. Either declare it where the author reads the limit (flows.mdx, the changeset: "best-effort when the cache service faults") or fail the node on a counter fault for THIS recipient class, since a visitor-caused send is the case the ruling limits. Seat's call; not a blocker.
  • F4 — a machine-readable surface will lie between this landing and 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. The recipients describe in packages/spec/src/automation/io-node-config.zod.ts (and the generated content/docs/references/automation/io-node-config.mdx row) says delivery is not live; after this merges it is. The claim forbids packages/spec/** and ruling 6386629237 hands the describe to domain:spec (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), so this is a sequencing flag for the seat: land 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 with or immediately after this PR, not a release later.
  • F5 — ASCII-only address grammar is undeclared (①.5). State it in flows.mdx and the changeset, or widen the label/atom classes; fix the refusal text either way.
  • Open question 2 (5 sends/address/hour): the ruling fixes no figure; a constant in one place is acceptable. Noted, no action.
  • Dev deviations 1–2 (Clause-② line, stale readings): answered by the 02:15Z body edit and the green re-run. Deviation 3 (files outside the claimed surface): accepted with the correction in ①.7. Deviations 4–6: no bearing on the contract.
  • Out-of-scope finding (the email validator admits separators and selector prefixes in the local part, record-validator.ts): a real class-c producer; the seat says it files it after landing — fine, it is not this PR's.

Implemented-by: claude/project-thread-e1l64i
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

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

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d4fd5968a5a9002e3aa9c35ceadb1849a9e13f47
Local-runs: none
Scope: packages/services/service-automation/src/builtin/notify-node.ts, packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts, packages/services/service-automation/package.json, packages/services/service-automation/vitest.config.ts, pnpm-lock.yaml, scripts/engine-double-contract.pinned.json + the cited chain read for the screen's two address readings: packages/services/service-messaging/src/recipient-resolver.ts (user: strip, looksLikeEmail), packages/services/service-messaging/src/email-channel.ts (EMAIL_SHAPE literal branch), packages/plugins/plugin-auth/src/rate-limit-storage.ts and its package exports. The prose faces (.changeset/25571-notify-email-field-delivery.md, content/docs/automation/flows.mdx) were read as the declarations the code is judged against.

Written 2026-10-25T03:02Z · face: narrowing (Clause-②: no (narrowing: …) + Metadata-protocol: narrowed, byte-identical on the PR body and the changeset) · inputs: the prior record 6392441398 on head 450832fb (FAIL, F1–F5), seat ACCEPT 6392365089, os-dev-report 6392322238 on #25571, rulings 6377887204 (#25571 B) and 6386629237 (#25647 B), the net diff of this head (8 files, +773/−13) and the 47 check runs on it. Read-only: nothing built, run or re-run. This record re-reads the prior record's five findings against the one commit between the two heads and does not restate what that record already established as RIGHT (①.1, ①.4, ①.6) unchanged here.

① Derived judgments

  1. F1 closed — the user: spelling is now screened on the mail path. RIGHT. resolveRecipients runs each envelope-computed recipient through mailedAddressOf, which mirrors the chain's two address readings: a value with the user: prefix is stripped once and the remainder is screened when it reads as an address; a bare value is screened when it reads as an address; role: and team: yield user ids and pass. The doubled spelling user:user:a@b.c is covered by construction: one strip leaves user:a@b.c, which readsAsAddress accepts (one @, a dotted domain), so it is looked up as an address, which no sys_user.email holds, and the node fails. screenComputedAddresses is unchanged in behaviour (user holds it → the recipient becomes that user's id; no user, no data engine or a failing lookup → the node fails, located and pointing to { emailField }). Pinned: E7 (it.each over user:ADDRESS and user:user:ADDRESS from record text under channels: 'email' → run fails, message names the source and "no user holds it", outbox empty, nothing mailed after a dispatcher tick) and E8 (user: + a user's address → outbox row with that user's id). The channel gate (toStringList(cfg.channels).includes('email')) is unchanged, so E5/E6 keep their meaning.

  2. owner_of: left unscreened — decided in writing, accepted. The prior record asked for a written decision. The docblock gives one: owner_of: mails a stored owner field that record writes put there, not the expression, so it is outside ruling B's object (an address an expression builds from text a visitor typed). That is a defensible reading of 6386629237 B and matches the prior record's own classification ("the address there comes from a stored field, not from the visitor's text"). Not a finding; recorded so the next reader does not re-open it.

  3. F3 closed — the limit's fail-open is declared. Changeset: "The limit is best-effort: when the counter faults, the send is admitted and a warning is logged once." flows.mdx carries the same sentence in the recipients paragraph. The code is unchanged (admit on a counter fault, one warn per process). Declaration and behaviour now agree.

  4. F5 closed — the ASCII-only grammar is declared and the refusal text is true. Changeset and flows.mdx both name what the email field type accepts but this node refuses: a non-ASCII character, a quoted local part, an IP-literal domain. The run-time refusal now reads "does not hold one plain ASCII email address … no list, display name, separator, quoted local part, IP-literal domain or non-ASCII character", so a stored internationalised address is refused with a true sentence. The grammar itself is unchanged (ASCII atext atoms, ASCII hostname labels, linear scans).

  5. Dependency-edge wording corrected. The changeset now says the dev → runtime move of @objectstack/plugin-auth IS a new workspace edge, service-automation → plugin-auth, and states the no-cycle argument (neither plugin-auth, service-messaging nor rest depends on service-automation). Validate Package Dependencies is green on this head. The pnpm-lock.yaml importer hunk, the vitest.config.ts source alias and the engine-double-contract.pinned.json row are the same gate-forced files the seat accepted.

  6. Public surface unchanged by this commit. No new export beyond the two constants the prior record judged module-local to the published surface (builtin/index.ts re-exports registerNotifyNode only). packages/spec/**, engine.ts and service-messaging/** stay untouched, as the body claims and the file list shows.

② Semver level

  • Changeset .changeset/25571-notify-email-field-delivery.md: '@objectstack/service-automation': minor, the one released package whose source moves. Metadata-protocol: narrowed with the (narrowing) arm present and the declared refusal (an envelope-computed address, bare or user:-prefixed, that no user holds, in a node whose channels include email) now matching what the runtime enforces (①.1), which is what the prior record withheld PASS for. Title carries ! in body and changeset. One ADR-0087 marker, not-required (no-migration-prescription), the other five categories closed on facts in its text. Check Changeset success on this head. Correct as declared.
  • Check runs on d4fd5968 (47 at 03:00Z): every run completed; 35 success, 12 skipped by path filter (UI lanes, Console Pin Gate, Temporal Conformance, the sharded Dogfood matrix, the two opt-in harnesses); none failure, none pending. Required contexts all green: Lint & Repo Gates, TypeScript Type Check, Test Core and shards 1/6–6/6, Build Core, Governed Surface Queue Guard, Check Changeset, Validate Package Dependencies. mergeable_state: clean.

③ Boundary flags

Dev report: 6392322238 on #25571. Seat ACCEPT: 6392365089 (at 450832fb; this head adds one commit on the same surface, no new file). PR author login objectstack-fleet[bot] (nothing to note). PR is draft; landing is the seat's through the relay now that this record exists.

Implemented-by: session_01CmnfU7vRbNtSFtBQwyyKpB
Reviewed-by: session_01Bf4vhia9fHNXpqC1PCfxie

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. needs:contract-review removed in this stroke.


Generated by Claude Code

Merged via the queue into main with commit d1e329e Oct 25, 2026
66 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-e1l64i branch October 25, 2026 04:41
This was referenced Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment