Skip to content

fix(service-automation): a notify node naming email and another channel mails an unheld written-out address and writes no inbox row for it - #26039

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-puin6e
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-puin6e

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #25990

Clause-②: no
Metadata-protocol: none

The sibling of #25758 (PR #25964), under the card's ruling: on a notify node naming both inbox and email, a written-out address no user holds goes to the email channel only, the inbox writes no row for it, and the node still succeeds. Identity: claim 6412253095 (branch claude/project-thread-puin6e).

What changed

notify node with channels naming email and another channel Before Now
written-out address no user holds, bare mailed, AND a sys_inbox_message row whose user_id is the address; success mailed only, no inbox row; success
user:ADDRESS (any address, held or not) same junk row (the resolver hands the remainder on as the id with no lookup) mailed only; success
address whose holder cannot be looked up (no data engine, lookup throws) junk row + mail; success mailed only; success
written-out address a user holds, bare that user's inbox + mail unchanged
computed address on a mailing node resolved to its user, or the node fails (ruling-B screen) unchanged
node without email fails on an unheld address (#25964) unchanged
  • service-automation notify-node.ts: new mailOnlyAddresses collects the written-out recipients only the email channel can deliver, using the exact test refuseUndeliverableAddresses (fix(service-automation)!: a notify node that does not name email fails on a written-out address no user holds #25964) makes, now shared as holderOf (the sys_user by email lookup, id projected, system read) and namesAddressAsUserId (the user: rule). screenComputedAddresses and refuseUndeliverableAddresses call the same holderOf, so there is one address test in the file, not a second resolver.
  • When the node mails and names another channel, execute emits those recipients apart with channels: ['email'], and every other recipient as before on the node's channels. notificationId names the every-channel notification (the one the inbox shows) when there is one; delivered, enqueued and failed add up both emits. A node with no such recipient makes exactly the one emit it made before.
  • The recipients help served to the Studio inspector says what such an address does.
  • scripts/engine-double-contract.pinned.json: the new test's findOne double, written by check:engine-double-contract --write.
  • Changeset: @objectstack/service-automation patch.

Where the skip lives (PM hypothesis, measured)

In the node, not the inbox channel. Measured on main b0384f3f:

  • EmitInput (service-messaging/src/messaging-service.ts:75) carries ONE audience and ONE channels list; there is no per-recipient channel restriction a producer can pass, so a node-side skip is two emits, and a messaging-side skip would be a new field on the service contract.
  • RecipientResolver.resolveEmail keeps an unheld address verbatim on purpose ("a downstream channel may still key a row by it"), and the inbox channel keys user_id by whatever recipient it receives. Making the inbox refuse a non-id would change what user_id means to that channel for every producer (collab.mention, audit writers, any emit), and on the outbox path it would still enqueue a sys_notification_delivery row that then fails.
  • The node is the one place that knows a recipient was WRITTEN OUT as an address and which channels its author named, so the skip is there, and every channel and the resolver keep their one meaning of a recipient.

Is it a narrowing (measured against AGENTS.md)

No. Every node that succeeded still succeeds and every node that failed still fails: the split runs only after the parse, the ruling-B screen and the #25758 refusal have answered, and it only chooses which channels an address is handed to. No key, value or stored shape moves and the parse is unchanged, so Clause-②: no, Metadata-protocol: none, patch. The claim carried Clause-②: yes (expected: … unchanged; the dev measures); measured, the accept set is unchanged in both directions, so the line is no with no arm.

Tests

  • New notify-both-channels-unheld-address.integration.test.ts (7 cases), real MessagingService (inline fan-out), real inbox and email channels, data engine recording every row: bare unheld address, user:ADDRESS unheld and held, a list beside a user (inbox row for the user only, both mailed, selected: 2, acted: 3, two sys_notification events), a directory lookup that throws; controls: a held bare address reaches its user's inbox and is mailed, a computed held address reaches its user's inbox.
  • Red first (commit da686d76, before the fix, on main b0384f3f): 5 failed (each on the inbox row whose user_id is the address), the 2 controls passed.
  • Ablation (scripts/ablation-replace.mjs, one behaviour: the mailOnlyAddresses call replaced by an empty set; the test imports the node from src, no dist in the path): 5 failed, 17 passed across this file and notify-written-address-channels.integration.test.ts (the 5 are exactly the fixed cases). Restore: blob e7907826 equals HEAD, git diff HEAD empty.

Verification

Under the verify lock, at 1ce55062 (the ledger pin 745715539 after it changes no source):

  • pnpm --filter @objectstack/service-automation test: 205 files / 2689 passed; typecheck (with check:test-typecheck): exit 0.
  • eslint --no-inline-config packages/services/service-automation: exit 0.
  • Derived gates (dispatch-gates --commands, 76 families on the final diff): 75 exit 0; check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (needs a full-repo build; CI runs it). Reconciled with --ran: 76 accounted, 0 unrun.
  • Open PRs on notify-node.ts: none found (open-PR search on notify / service-automation, 2026-10-26T10:02Z).

Acceptance notes

  • A run whose recipients mix users and such addresses now writes two sys_notification events for one node (users on the node's channels, addresses on email). The node's output keeps its keys; notificationId names the first. If the second emit throws after the first was accepted, the node fails with the users' notification already written: a partial outcome one emit could not have. emit() throws only before its fan-out (a malformed keyed text, an event write the engine refuses), and both emits carry the same input, so the second refusing after the first was accepted needs the engine to change its answer between them.
  • The rule covers every channel beside email, not only inbox: on a node naming email and sms, such an address is no longer handed to sms, where it used to be a failed delivery (no phone number for recipient), so failed drops by one for it.
  • @objectstack/spec describe text for recipients (io-node-config.zod.ts) does not say what a both-channels node does with an unheld address; that is the domain:spec text, not in this PR.

Generated by Claude Code

… row for an unheld written-out address (red)

Red on main b0384f3: 5 failed (the inbox row whose user_id is the address,
for a bare address, user:ADDRESS, user:HELD, a list beside a user, and a
failed directory lookup), 2 controls passed.

Claude-Session: https://claude.ai/code/session_01617Hq2GbkLYhXsfVgtvNuR
Co-authored-by: Claude <noreply@anthropic.com>
…el mails an unheld written-out address and writes no inbox row for it

The node emits a written-out address that only the email channel can
deliver (bare and held by no user, user:ADDRESS, or one whose holder could
not be looked up) apart, on the email channel alone; every other recipient
is emitted as before. The held test is the one refuseUndeliverableAddresses
makes, now the shared holderOf / namesAddressAsUserId.

Claude-Session: https://claude.ai/code/session_01617Hq2GbkLYhXsfVgtvNuR
Co-authored-by: Claude <noreply@anthropic.com>
… in the engine-double ledger

Written by check:engine-double-contract --write.

Claude-Session: https://claude.ai/code/session_01617Hq2GbkLYhXsfVgtvNuR
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 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
  • 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 431f065177de0cfc9d682b3b18692132390e0c57 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8b8eb6085c83c2a62437b5c18a91bb1deaa16413 — the merge of head 745715539041813bf60dc8370647e742ea5a4709 into base 431f065177de0cfc9d682b3b18692132390e0c57, 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 8b8eb6085c83c2a62437b5c18a91bb1deaa16413 && git checkout 8b8eb6085c83c2a62437b5c18a91bb1deaa16413
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 431f065177de0cfc9d682b3b18692132390e0c57 745715539041813bf60dc8370647e742ea5a4709 && git checkout -B drift-repro 431f065177de0cfc9d682b3b18692132390e0c57 && git merge --no-ff 745715539041813bf60dc8370647e742ea5a4709

node scripts/docs-audit/affected-docs.mjs --json 431f065177de0cfc9d682b3b18692132390e0c57

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T10:04Z

Head 7457155390, report 6413321515 (on #25990), claim 6412253095. Seat review of the net diff; no contract review owed (no packages/spec/** path, no narrowing of a request surface).

item reading
Shape draft, base main, Fixes #25990, Clause-②: no (the claim expected yes, unchanged surface; the dev measured no, and the diff adds no accepted input and refuses none), Metadata-protocol: none, patch on @objectstack/service-automation. Four paths: notify-node.ts, one integration test, the changeset, and the engine-double-contract pin for the new test's findOne.
Fix on a notify node whose channels name email and another channel, a written-out address only email can deliver (bare and unheld, user:ADDRESS, or a holder lookup that failed) is emitted apart on ['email']; every other recipient is emitted as before. The inbox writes no row keyed by an address. The holder test is one helper (holderOf / namesAddressAsUserId) shared with the #25758 and #25964 checks, so "a user holds it" means one thing in all three.
Unchanged the node's success; a held bare address still reaches the holder's inbox and is mailed; a computed address still resolves or fails as before; a node not naming email still fails on an unheld address.
Run record a mixed list writes two sys_notification events; notificationId names the every-channel one (the mail-only one when it is the only emit); delivered / enqueued / failed add up both. Declared in the changeset.
Evidence red first (5 failed / 2 controls passed at the base), 7/7 on the head, ablation red then restored; package 205 files / 2689 passed; 75/76 derived gates exit 0, dual-build-cjs-loads answered by CI's Build Core. Auto-merge holds for the head's check-runs.
Noted the @objectstack/spec describe text for notify.recipients does not yet say this (domain:spec text half); the node's own description does.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready + auto-merge through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 26, 2026 10:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 26, 2026 10:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit d12b0c1 Oct 26, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-puin6e branch October 26, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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)

2 participants