Repository navigation
fix(service-automation)!: a notify node that does not name email fails on a written-out address no user holds - #25964
Conversation
…de that does not name email (red) On main the node reports success and the inbox writes a row whose user_id is the address. Seven cases fail on main: every refusal case, and the no-directory case. The three controls pass. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
…s on a written-out address no user holds Only the email channel sends to an address. On a node whose channels do not include email, a written-out address no user holds (or one written as user:ADDRESS) used to reach the inbox as a user id no inbox reads, and the node reported success. It now fails before anything is written, naming the node, the recipient and the fix. Never re-routed to email. The app-showcase node alert_exec wrote out exec@example.com on the inbox; it now notifies role:owner. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
…'s findOne double check:engine-double-contract --write: the integration test's data-engine double is copied from the email-field recipient test and asserts the findOne predicate; the ledger records it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 f3ad3e7f5fce27a83a68adfd16c61b496cee0825 && git checkout f3ad3e7f5fce27a83a68adfd16c61b496cee0825
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 05f7758f054d70f3ba0f709b37423124be3e6e3d 45c95ee3ad11da9789615fd72d088f81c5aa0f3f && git checkout -B drift-repro 05f7758f054d70f3ba0f709b37423124be3e6e3d && git merge --no-ff 45c95ee3ad11da9789615fd72d088f81c5aa0f3f
node scripts/docs-audit/affected-docs.mjs --json 05f7758f054d70f3ba0f709b37423124be3e6e3d |
…le refuses a combinator check:where-matcher named the double's findOne matcher silently wrong: it read a combinator key as a field name. It now refuses one, as the email-field recipient test's double does. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
… fails on a computed address no user holds (option B) Prepared for the maintainer's decision on the #25758 open question; revert this commit if option A is chosen. refuseUndeliverableAddresses now judges every address-shaped recipient, written out or computed, on a node whose channels do not include email: where the address came from does not change who can receive it. A computed one is named by its envelope's source, never by its value. On an email node, ruling B's screen answers as before. Red first: on 011221d the three new computed refusal cases failed (run.success true), the two controls passed. E5 in the email-field suite pinned the old pass-through and now pins the refusal. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
…has someone to deliver to (option B) Part of option B (revert with d352c68 if option A is chosen). The approval suite's stack had no sys_user, and it pinned the inbox row's user_id as the owner's email address, the row no inbox reads. It now seeds the user who holds that address and pins that user's id. Also words the computed-address refusal without repeating "is an email address". Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
Contract reviewServed-tier: Inputs: card #25758 body and comments 6396218293, 6408881630, 6410135308; PR #25964 body, file list and ① Derived judgmentsAccept-set and run-time changes the diff implies (read in
Email-channel default and channel list. Value-expression path. Existing-test edits: fixes of off-contract fixtures, not loosening.
Pinned-contract rows. One row added to Would the A subset pass? On the code read, yes: 47c6ea0 + e6878f8 + 011221d carry the written-out refusal, the new test's A to D cases, the ledger row, the Why the head as it stands fails (B). The B changeset's "Who moves" paragraph states: the app-showcase's inbox nodes whose expressions read
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…ot a user, through notify_cleared (red) The approval suite now boots the directory a dev boot has (the admin as the Default Organization's owner member, a provisioned persona) and opens the sign-off on the app's own INV-1010 seed row, whose owner grace@example.com holds no account. Red until notify_cleared points at a recipient a user holds. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
…ers, not the seeded invoice owner no user holds Contract review F1, F6. The seeded invoices' owners are the ADR-0055 isolation fixture and deliberately not users (the dogfood isolation test signs up as ada@example.com), so approving the seeded Invoice Dual Sign-off demo failed the run at notify_cleared under the computed-address check. It now notifies role:owner, the admin who clears it on a dev boot. The changeset names notify_cleared and the four admin@objectos.ai inbox nodes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk
Contract reviewServed-tier: Inputs: card #25758 body and comments 6396218293, 6408881630, 6410135308 and the updated os-dev-report 6411976679; PR #25964 body, file list, ① Derived judgmentsWhat moved since the FAIL on Is
Is the new test a real pin, and is it red on the old flow? It is a real pin. Does the rest of the showcase still hold under B? Read against the flows at the head: the only notify producer over A consequence the delta leaves behind, cosmetic. With ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Ruling | option B, the maintainer's choice on the seat's decision card (2026-10-26T08:02Z): a recipient a value expression computes is judged like a written-out one. |
| Shape | draft, base main, Fixes #25758, Clause-②: no (narrowing: …) and Metadata-protocol: none, @objectstack/service-automation: patch with the BREAKING banner; the showcase is private. |
| Fix | a notify node whose channels do not include email fails at run time, named by the recipient's source and never its value, when a recipient (written out or computed, bare or user:ADDRESS) is an email address no user holds; email nodes and held addresses are unchanged. |
| Producers moved | showcase alert_exec and notify_cleared now notify role:owner (the seeded invoice owners stay the unprovisioned ADR-0055 isolation fixture), pinned by the INV-1010 approval test, red on the old flow; the four admin@objectos.ai inbox nodes are named in the changeset (F1, F6 discharged). |
| Evidence | red first, ablation, 79/79 derived gates, dogfood 2523 green locally at the head, every check-run green or an expected skip. |
| Carried | F9 (a role:owner audience with no owner member reports success with zero deliveries, the standing role: contract) and F10 (stale KNOWN_EXEMPT / demo-personas.ts wording): non-blocking polish. #25990 carries the both-channels inbox row; the spec describe text is the domain:spec half. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready + auto-merge through the relay. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #25758
Clause-②: no (narrowing: a notify node whose channels do not include email, and whose recipients hold an email address no user holds, written out or evaluated by a value expression, bare or as user:ADDRESS, now fails the node at run time instead of reporting success; the seat runs the contract review before the queue)
Metadata-protocol: none
The server half of the card (triage
6396218293), under the seat's ruling: fail loud, never re-route. The authoring refusal atos validate/ publish is adomain:speccard and is not in this PR.What changed
Only the email channel sends to an address itself. The inbox keys its row by the recipient as a user id, and the sms channel texts a user's number or a literal phone number. So on a node that does not name
email, a written-out address reaches someone only when the messaging resolver maps it to the user who holds it, and the resolver maps only a bare address (it hands the remainder ofuser:a@b.con as the id with no lookup).notifynodechannelsabsent (inbox),inbox, orsmssys_inbox_messagerow with the address inuser_id, which no inbox reads; node successuser:ADDRESS(any address)channelsincludeemaild352c68a+55f6fc27)service-automationnotify-node.ts:resolveRecipientsnow also returnswritten, each recipient written out as a string that reads as an address (the samemailedAddressOfreading the ruling-B screen uses). NewrefuseUndeliverableAddressesruns only whenchannelsdo not includeemail: auser:ADDRESSentry is refused outright; a bare address is looked up as the resolver looks it up (sys_userbyemail, the id projected, system read), and refused when no user holds it. All such recipients of a list are named in one message. The node never adds theemailchannel and never writes the row. Therecipientshelp served to the Studio inspector says what a written-out address does.notify: node `alert_exec` names no `channels`, so it delivers to the inbox, the default, and writes out a recipient no user receives: `exec@example.com` (no user holds that address). Only the email channel sends to an address; the others deliver to a user, so nobody would receive it, and nothing was sent. To mail the address as written, add `email` to the node's `channels`. To reach a user, name the user: a user id, an audience selector such as `role:NAME`, or the address that user holds, written bare.examples/app-showcasenodealert_exec(flowshowcase_project_escalation):recipients: 'exec@example.com'on the inbox becomesrecipients: 'role:owner'. Measured: the showcase's mail goes throughplugin-email's defaultlogtransport, so addingemailwould only log a mail toexample.com;role:ownerreaches the organization's owners, andensure-default-organization.tsmakes the admin the Default Organization'sowner, so the "Alert Exec" branch now reaches a real inbox.examples/app-showcasenodenotify_cleared(flowshowcase_invoice_signoff, option B, contract review6411272864F1):recipients: 'record.owner'on the inbox becomesrecipients: 'role:owner'. The seeded invoice owners (ada@, linus@, grace@example.com) are the ADR-0055 isolation fixture and must stay unprovisioned (showcase-invoice-seed-isolation.dogfood.test.tssigns up as ada@), so under B a computed owner address no user holds would fail the node; the Default Organization's admin, who holds the finance and legal positions that clear the request, receives it instead.admin@objectos.aion the inbox (digest,log_fail,notify_done,report): they hold on a dev boot, where the admin is provisioned, and fail loud on a boot without that user (contract review F6); named in the changeset.scripts/engine-double-contract.pinned.json: the new test'sfindOnedouble, written bycheck:engine-double-contract --write.@objectstack/service-automationpatch, BREAKING banner, ADR-0087not-required (no-migration-prescription).Where the check lives (PM hypothesis, measured)
Channels registered on
main(messaging-service-plugin.ts):inbox,email,sms, no others in the repo. Which take a user id:inboxonly a user id, and silently keys any value (inbox-channel.ts:149,:205);smsa user id or a literal phone number, and an address there already fails the delivery (no phone number for recipient);emaila user id or an address. The node is the one place that knows both the recipient's provenance (written or computed) and the node'schannels, so the check is there and covers inbox and sms alike. No channel changes.Is it a narrowing (measured against AGENTS.md)
Yes, at run time: a run that used to complete now fails at this node, and a failing node in a
parallelbranch or before later nodes changes what the run does. The parse andos validateare unchanged and no key, value or stored shape moves, soMetadata-protocol: none,patch, the precedent of PR #25954 and of the run-time half in.changeset/25572-*. The claim'sClause-②: nocarried no arm; this PR adds(narrowing: …), so the contract review is owed.Tests
notify-written-address-channels.integration.test.ts(10 cases), realMessagingService(inline fan-out), real inbox and email channels, data engine recording every row: refusal for inbox default,inbox,sms,user:ADDRESS(unheld and held), a list naming two; controls: an address a user holds reaches that user's inbox,emailandinbox+emailnodes mail the address as written; no data engine refuses.examples/app-showcase/test/approval-resume-relation-expand.test.ts: boots the dev-boot directory (admin as owner member, a provisioned persona) and approves the app's own INV-1010 seed row throughnotify_clearedtoend_ok, the notice landing in the admin's inbox; red on the old flow atd0e593bb(RESUME_FAILEDatnotify_cleared).fc0c961b, before the fix, onmain17c5e68b): 7 failed (every refusal case and the no-engine case, eachexpected true to be falseonrun.success), 3 controls passed.scripts/ablation-replace.mjs, one behaviour: the refusal's early return,written.length === 0to>= 0; the test imports the node fromsrc, nodistin the path): 7 failed, 26 passed (the 7 are exactly the refusal cases; the email-field suite stays green). Restore: blob33f2d3a7equals HEAD,git diff HEADempty.Verification
At head
47c6ea05unless noted, under the verify lock:pnpm --filter @objectstack/service-automation test: 204 files / 2677 passed;typecheck(withcheck:test-typecheck): exit 0.pnpm --filter @objectstack/example-showcase test: 39 files / 484 passed;typecheck: exit 0 (after building its dependency closure).eslint --no-inline-configon the three changed TypeScript files: exit 0.os-dev-reporton notify: a written-out email address no user holds, on a node that does not name the email channel, writes an inbox row nobody can read and the node reports success #25758.Acceptance notes
channelsinclude bothinboxandemail, an address no user holds is mailed as written (ruling B), and the inbox delivery for it still writes a row whoseuser_idis the address. Filed as notify: a node naming both inbox and email mails an unheld written-out address and still writes an inbox row addressed to it (sibling of #25758) #25990.@objectstack/specdescribe text forrecipients(io-node-config.zod.ts) still says a written-out address goes "to the user who holds it, or else, on theemailchannel, to the address itself" and does not say a node withoutemailfails. That is thedomain:spechalf's text.claude/project-thread-hdpxcs; the claim comment6408881630is the identity record.🤖 Generated with Claude Code
https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk