Skip to content

fix(service-automation)!: a notify node that does not name email fails on a written-out address no user holds - #25964

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

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

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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 at os validate / publish is a domain:spec card 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 of user:a@b.c on as the id with no lookup).

notify node Written-out recipient Before Now
channels absent (inbox), inbox, or sms address no user holds sys_inbox_message row with the address in user_id, which no inbox reads; node success node fails, names node, recipient and fix; nothing written
same user:ADDRESS (any address) same junk row, node success node fails
same address a user holds, bare that user's inbox unchanged
same no data engine, or the lookup fails inbox not persisted / junk row, node success node fails (the address could not be checked)
channels include email any address sent as written (ruling B) unchanged
any recipient a value expression computes per ruling B scope A: junk row, node success judged like a written-out one, named by its source, never its value (option B, ruled by the maintainer 2026-10-26T08:02Z: commits d352c68a + 55f6fc27)
  • service-automation notify-node.ts: resolveRecipients now also returns written, each recipient written out as a string that reads as an address (the same mailedAddressOf reading the ruling-B screen uses). New refuseUndeliverableAddresses runs only when channels do not include email: a user:ADDRESS entry is refused outright; a bare address is looked up as the resolver looks it up (sys_user by email, 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 the email channel and never writes the row. The recipients help served to the Studio inspector says what a written-out address does.
  • Sample message: 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-showcase node alert_exec (flow showcase_project_escalation): recipients: 'exec@example.com' on the inbox becomes recipients: 'role:owner'. Measured: the showcase's mail goes through plugin-email's default log transport, so adding email would only log a mail to example.com; role:owner reaches the organization's owners, and ensure-default-organization.ts makes the admin the Default Organization's owner, so the "Alert Exec" branch now reaches a real inbox.
  • examples/app-showcase node notify_cleared (flow showcase_invoice_signoff, option B, contract review 6411272864 F1): recipients: 'record.owner' on the inbox becomes recipients: '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.ts signs 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.
  • Showcase nodes that keep a written-out admin@objectos.ai on 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's findOne double, written by check:engine-double-contract --write.
  • Changeset: @objectstack/service-automation patch, BREAKING banner, ADR-0087 not-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: inbox only a user id, and silently keys any value (inbox-channel.ts:149, :205); sms a user id or a literal phone number, and an address there already fails the delivery (no phone number for recipient); email a user id or an address. The node is the one place that knows both the recipient's provenance (written or computed) and the node's channels, 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 parallel branch or before later nodes changes what the run does. The parse and os validate are unchanged and no key, value or stored shape moves, so Metadata-protocol: none, patch, the precedent of PR #25954 and of the run-time half in .changeset/25572-*. The claim's Clause-②: no carried no arm; this PR adds (narrowing: …), so the contract review is owed.

Tests

  • New notify-written-address-channels.integration.test.ts (10 cases), real MessagingService (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, email and inbox+email nodes 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 through notify_cleared to end_ok, the notice landing in the admin's inbox; red on the old flow at d0e593bb (RESUME_FAILED at notify_cleared).
  • Red first (commit fc0c961b, before the fix, on main 17c5e68b): 7 failed (every refusal case and the no-engine case, each expected true to be false on run.success), 3 controls passed.
  • Ablation (scripts/ablation-replace.mjs, one behaviour: the refusal's early return, written.length === 0 to >= 0; the test imports the node from src, no dist in the path): 7 failed, 26 passed (the 7 are exactly the refusal cases; the email-field suite stays green). Restore: blob 33f2d3a7 equals HEAD, git diff HEAD empty.

Verification

At head 47c6ea05 unless noted, under the verify lock:

Acceptance notes

  • Out of this PR's rulings: on a node whose channels include both inbox and email, an address no user holds is mailed as written (ruling B), and the inbox delivery for it still writes a row whose user_id is 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/spec describe text for recipients (io-node-config.zod.ts) still says a written-out address goes "to the user who holds it, or else, on the email channel, to the address itself" and does not say a node without email fails. That is the domain:spec half's text.
  • Branch is the harness-assigned claude/project-thread-hdpxcs; the claim comment 6408881630 is the identity record.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GUgFY2BFn32b13fpwkZ3Jk

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 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
  • 3 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 05f7758f054d70f3ba0f709b37423124be3e6e3d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f3ad3e7f5fce27a83a68adfd16c61b496cee0825 — the merge of head 45c95ee3ad11da9789615fd72d088f81c5aa0f3f into base 05f7758f054d70f3ba0f709b37423124be3e6e3d, 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 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

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

…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 55f6fc2730781c03717264b72f9acfe8e81ec62f
Local-runs: none
Scope: packages/services/service-automation/src/builtin/notify-node.ts, packages/services/service-automation/src/builtin/notify-written-address-channels.integration.test.ts, packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts, examples/app-showcase/src/automation/flows/index.ts, examples/app-showcase/test/approval-resume-relation-expand.test.ts, scripts/engine-double-contract.pinned.json + packages/services/service-messaging/src/recipient-resolver.ts, packages/services/service-messaging/src/inbox-channel.ts, packages/services/service-messaging/src/email-channel.ts, packages/services/service-messaging/src/sms-channel.ts, packages/services/service-messaging/src/messaging-service.ts, packages/services/service-automation/src/builtin/value-slot-map.ts, scripts/check-engine-double-contract.mjs, examples/app-showcase/test/inert-wirings.test.ts, examples/app-showcase/src/security/demo-personas.ts, examples/app-showcase/src/data/seed/index.ts, examples/app-showcase/src/security/seed-approval-demo.ts, packages/plugins/plugin-auth/src/ensure-default-organization.ts, .changeset/25758-notify-written-address-no-email-channel.md (prose face, read for ②)

Inputs: card #25758 body and comments 6396218293, 6408881630, 6410135308; PR #25964 body, file list and git diff origin/main...55f6fc27; the 54 check-runs on the head (all concluded: 0 failed, 0 in progress; Check Changeset, Lint & Repo Gates, TypeScript Type Check, Test Core and its 8 shards, Build Core, Dogfood Regression Gate, Governed Surface Queue Guard all success). The head is option B: commits d352c68a (computed recipients judged too) and 55f6fc27 (the showcase test seeds a user). The A subset is the head minus those two.

① Derived judgments

Accept-set and run-time changes the diff implies (read in notify-node.ts at the head against the messaging code on main):

notify node recipient before (main) after (head) judgment
channels absent, ['inbox'], or sms bare address, no sys_user.email matches resolver keeps it verbatim (recipient-resolver.ts resolveEmail), inbox writes a row with user_id = the address (inbox-channel.ts:149, :205); sms fails the delivery row (no phone number for recipient); node reports success node fails before emit; message names the node, the channels or their absence, each such recipient in backticks, (no user holds that address), and the fix (add email, or a user id / role:NAME / the held address bare); nothing written, nothing mailed (test A, 5 cases + the list case) right. Only the email channel sends to an address (email-channel.ts EMAIL_SHAPE(recipient) branch); inbox and sms deliver to a user id. The lookup is the resolver's own (sys_user by email, fields: ['id'], system read).
same bare address a user holds resolver maps it to the user id; that user's inbox unchanged (test B) right
same user:ADDRESS, held or not resolver strips user: and hands the address on as an id with no lookup; junk row; success node fails outright, (user: takes a user id, not an email address) (test A, 2 cases) right in substance and consistent with mailedAddressOf, which already reads user:a@b.c as an address for the ruling-B screen and the email channel. One accept-set edge, noted not blocking: a sys_user.id that is itself address-shaped is refused without an id lookup; no in-repo producer writes user: plus an address (git grep on main finds only DSN examples).
same no data engine, or the lookup throws inbox not persisted (warn) or junk row; success node fails, the message says the address could not be checked (test D; E5 of the email-field suite) right: parity with screenComputedAddresses. Cosmetic: with no data engine the loop returns on the first bare address, so a user:ADDRESS entry collected before it is not named in that one message; the node still fails.
channels include email (alone or with inbox) written-out address sent as written (ruling B) unchanged: refuseUndeliverableAddresses runs only under !mails (test C, 2 cases) right
channels include email computed address no user holds fails through the ruling-B screen pointing to { emailField } unchanged (test E, last case) right
channels absent, ['inbox'], or sms address a CEL envelope computes, no user holds it goes to messaging as before; junk row; success (E5 on main pinned exactly this, per ruling A on #25647 as the dev read it) B only: node fails, named by recipients[i] evaluated to an email address — source: ..., never the value (test E, 3 cases, not.toContain('stranger@outside.example')) code is right and the value never leaks (nameOf reads what and source only; the only text not under the node's control is err.message on a thrown lookup, the same exposure the ruling-B screen already has). Whether this behaviour may land is ③ F1.
same computed address a user holds that user's inbox unchanged (test E, held case) right

Email-channel default and channel list. MessagingService.emit defaults channels to ['inbox'] (messaging-service.ts:1114); messaging-service-plugin.ts registers inbox directly and email/sms through lazy providers; no fourth channel exists in the repo. The node reads channels raw (toStringList, static routing) and tests includes('email'), so the mails / !mails split is exhaustive and the where-clause text (names no channels, so it delivers to the inbox, the default versus names channels ... and not email) is accurate. Never re-routed: the node adds no channel (test A asserts mailed is empty on every refusal).

Value-expression path. resolveRecipients evaluates each envelope through resolveValueSlot (one evaluator, value-slot-map.ts), and only then reads the string with mailedAddressOf. The head moves the mailedAddressOf call out from under isExpressionEnvelopeShaped(raw) so a written string is read too; computed keeps its old population (envelope only), addressed takes both with provenance. screenComputedAddresses still consumes computed on a mailing node; nothing about the ruling-B screen moved. Right.

Existing-test edits: fixes of off-contract fixtures, not loosening.

  • notify-email-field-recipient.integration.test.ts E5 (B only): on main it asserted run.success === true and emitted[0].audience equal to ['stranger@outside.example'] on an inbox node, i.e. it pinned the junk-row behaviour. The head asserts failure, no emit, and the undeliverable message rather than the mail-screen message (bootUnchecked has no data service, so the no-engine branch answers). Stronger, and it is the ruling-A pin flipped to B; on the A subset it stays as on main.
  • approval-resume-relation-expand.test.ts (B only): on main both assertions expected notice.user_id to be the literal 'grace@example.com', the unreadable row. The head seeds usr_grace holding that address through the driver before any run (data at rest) and asserts the user id. Stronger. But see F1: this makes the test's world consistent while the showcase's own seed is not.
  • flows/index.ts alert_exec: 'exec@example.com' on the inbox default becomes 'role:owner'. role: expands through sys_member where role = owner in the run's tenant (recipient-resolver.ts resolveRole), and ensure-default-organization.ts inserts the admin's sys_member { role: 'owner' } row on the Default Organization. A producer fix, not a loosening. The three-line comment names the card number in source; AGENTS.md's no-tracker-number rule is for rules and runtime strings, and the codebase uses [#NNNN] code comments throughout, so no flag.

Pinned-contract rows. One row added to scripts/engine-double-contract.pinned.json: (notify-written-address-channels.integration.test.ts, findOne, pinned: 1), placed where the gate's sort (file, then verb; check-engine-double-contract.mjs:2627) puts it, between notify-organization-stamp and notify-zero-delivery-visibility. The test's double declares insert/find/findOne and its findOne calls assertEngineFindOnePredicate(object, query) imported from @objectstack/metadata-core, one of the slice's accepted modules, so PINNED and RETAINED both hold. Lint & Repo Gates is success on the head. Right.

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 alert_exec fix and a changeset whose text makes no claim about computed recipients. Its gate verdicts on its own head are not among this record's inputs (only the head's check-runs are), so an A record must be rendered on A's head once it exists. F6 below carries over to A as a disclosure item, not a blocker.

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 record.owner or record.assignee evaluate to addresses its seeds provision as users, pinned by inert-wirings.test.ts, so they keep delivering. Read in the cited code, that is false for one live producer:

  • inert-wirings.test.ts §5 carries KNOWN_EXEMPT = new Set(['showcase_invoice.owner']), and demo-personas.ts explains it is left addressed to non-users knowingly (the ADR-0055 isolation demo).
  • data/seed/index.ts writes showcase_invoice.owner as ada@example.com, linus@example.com, grace@example.com; PROVISIONED_USER_EMAILS is admin@objectos.ai, phone.demo@example.com, auditor.demo@example.com.
  • InvoiceDualSignoffFlow (showcase_invoice_signoff) node notify_cleared is channels: ['inbox'] with recipients: [{ cel: has(record.owner) ? record.owner : null }], and seed-approval-demo.ts opens a sign-off run on a seeded sent invoice at boot.
    So under B, approving a seeded invoice fails the run at notify_cleared with (no user holds that address) unless an operator has signed up as that owner. Before, it wrote the junk row and succeeded. Failing loud is the rule this PR adopts, so the behaviour is not wrong in itself, but: the diff moves a live in-repo producer it neither names nor fixes (the alert_exec fix shows the PR knows the producer is its job: Prime Directives chore: version packages #10 and Add comprehensive test suite for Zod schema validation #12), the changeset publishes the opposite claim into CHANGELOG.md, and the option-B recommendation on the card rests on the same misreading of the §5 pin. No gate on the head exercises that approval (the dogfood isolation test pins owners and does not approve; the showcase test suite seeds its own Grace), so Dogfood Regression Gate: success says nothing about it, and the dev report says the dogfood battery was not re-run on the B commits locally.

② Semver level

  • Changeset: @objectstack/service-automation patch, title with !, bold BREAKING (run time) line, Clause-②: no (narrowing: ...) and Metadata-protocol: none as line-initial declarations, one ADR-0087 marker not-required (no-migration-prescription) (a category check-adr-0087-registration.mjs lists). AGENTS.md: a non-protocol change, breaking, ships patch and keeps its BREAKING banner and disposition; none takes patch. Level right. Check Changeset is success (two runs on the head).
  • Metadata-protocol: none: the parse, os validate and every spec schema are unchanged; what moves is a run-time refusal of a value the contract still accepts. Precedent PR fix(security)!: a hook that runs as system and an action body write no identity assignment #25954 (073f08b3, on main) used exactly this pairing for a run-time refusal. Accepted, with the note that the domain:spec half (refusal at validate/publish) will be narrowed and at least minor when it lands.
  • PR body Clause-②: no (narrowing: ...) line: present, line-initial, same shape as fix(security)!: a hook that runs as system and an action body write no identity assignment #25954; it names the computed arm too (B). The recogniser's --paths mode refused these paths and asked for the face to be read off the Clause-②: line, which carries (narrowing): the narrowing face, CONTRACT_REVIEW_TIER, this record's tier.
  • Title !: matches the BREAKING banner. Right.
  • The changeset's substance under B carries the false "keep delivering" sentence (①). Under A (47c6ea0's text) the body is accurate: it names alert_exec and nothing else moves in the repo except F6.

③ Boundary flags

  • F1 (report open_questions, needs_decision, A versus B): escalated to the maintainer, with a correction to the brief it was handed. The recommendation says seeded owners are provisioned and pinned by inert-wirings.test.ts; that pin exempts showcase_invoice.owner by design, and B as implemented makes the seeded Invoice Dual Sign-off demo fail at notify_cleared (①). The maintainer's choice should weigh that fact. If B is chosen, the same PR must move notify_cleared (or state in the changeset that the demo now fails loud until the operator signs up as the owner) and rewrite the "Who moves" sentence; this record does not stand in for that word, and B must not be landed on it. Ruling A on [Decision] A notify node already mails a written-out or computed email address: does ruling 6377842908's 「⛔ 不接受自由文本或表达式拼出的地址」 reach it — refuse both (A), refuse computed addresses only (B), or leave both (C)? #25647 (6393736930), as the dev read it in the comment 47c6ea0 wrote and d352c68a deleted, leaves computed recipients on a non-mailing node to messaging as before; B refines it. A maintainer refinement is the only thing that lifts that.
  • F2 (report DEPARTURE: Clause-②: no on the claim became no (narrowing: ...)): right. A run that completed now fails at this node; the arm is owed and the review with it. Accepted.
  • F3 (report "reading chosen": written-out address read as an address no user holds): matches the card's title and the triage's "a written-out address on a node with no email channel lands in an inbox nobody reads"; a held address still reaches its user (test B). Accepted.
  • F4 (out_of_scope_findings 1: io-node-config.zod.ts describe text): the domain:spec half's carrier is named on the card by the claim (No packages/spec/**). Accepted as not this PR's.
  • F5 (out_of_scope_findings 2: inbox + email both named, unheld address mailed and a junk inbox row still written): reproducible defect with no carrier (承接者:无, "noted, not filed"). Prime Directive chore: version packages #10 asks for an issue, not a note. Escalated: file a card.
  • F6 (not in the report): four more written-out inbox nodes in the showcase. ScheduledDigestFlow digest, ProjectEscalationFlow log_fail, ReleaseSignoffFlow notify_done, InquiryPurgeFlow report node all write 'admin@objectos.ai' on inbox-only nodes. demo-personas.ts registers ADMIN_EMAIL as provisioned on a dev boot (maybeSeedDevAdmin, gated on NODE_ENV === 'development'), so they hold where the showcase documents itself as running; on a non-dev boot they now fail loud instead of writing junk, which is the rule. Neither the PR body nor the changeset names them. Disclose in the changeset; applies to A and B alike.
  • F7 (report dogfood_battery): run at 47c6ea0 only; not re-run on d352c68a/55f6fc27. The head's Dogfood Regression Gate is success, but no gate approves a seeded invoice, so green there is not evidence on F1.
  • F8 (branch): claude/project-thread-hdpxcs is the harness-pinned branch, not claude/issue-25758-*; the claim comment 6408881630 is the identity record and The card this PR closes must claim this branch is success. Accepted.
  • The PR is a draft and not governed-surface; nothing here is a seat act. No GitHub write, no local run, no checkout was made for this record.

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

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
@github-actions github-actions Bot added size/l and removed size/m labels Oct 26, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 45c95ee3ad11da9789615fd72d088f81c5aa0f3f
Local-runs: none
Scope: packages/services/service-automation/src/builtin/notify-node.ts, packages/services/service-automation/src/builtin/notify-written-address-channels.integration.test.ts, packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts, examples/app-showcase/src/automation/flows/index.ts, examples/app-showcase/test/approval-resume-relation-expand.test.ts, scripts/engine-double-contract.pinned.json + packages/services/service-messaging/src/recipient-resolver.ts, packages/services/service-messaging/src/messaging-service.ts, packages/plugins/plugin-auth/src/ensure-default-organization.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/walled-owner-verification-path.ts, examples/app-showcase/src/security/demo-personas.ts, examples/app-showcase/src/security/seed-approval-demo.ts, examples/app-showcase/src/data/seed/index.ts, examples/app-showcase/src/data/objects/task.object.ts, examples/app-showcase/test/default-organization.testkit.ts, examples/app-showcase/test/inert-wirings.test.ts, packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts, .github/workflows/ci.yml, .changeset/25758-notify-written-address-no-email-channel.md (prose face, read for ②)

Inputs: card #25758 body and comments 6396218293, 6408881630, 6410135308 and the updated os-dev-report 6411976679; PR #25964 body, file list, git diff origin/main...45c95ee3ad (7 files) and the delta git diff 55f6fc27 45c95ee3ad (3 files: the changeset, flows/index.ts node notify_cleared, approval-resume-relation-expand.test.ts); the 47 check-runs on the head (36 success, 11 skipped, 0 failed, 0 in progress: Test Core and its 8 shards, Build Core, TypeScript Type Check, Lint & Repo Gates, Check Changeset, Governed Surface Queue Guard, The card this PR closes must claim this branch all success; the skipped ones are the UI lanes, Build Docs, the opt-in harnesses, Temporal Conformance, Dogfood Verify CLI and the three Dogfood Regression Gate shards). This record re-judges the head under option B, as the maintainer ruled (2026-10-26T08:02Z per the PR body); the A subset is no longer a candidate.

① Derived judgments

What moved since the FAIL on 55f6fc27. The service-automation code, both of its integration tests and the pinned-contract row are byte-identical to the previous head: the delta touches only the showcase flow's notify_cleared node, the showcase approval test, and the changeset's "Who moves" prose. So the B accept-set table in the previous record (written-out bare address, user:ADDRESS, no data engine, mailing nodes unchanged, held address unchanged, computed address refused on a non-mailing node and named by source only) stands as judged there; nothing in ① below re-opens it, and Lint & Repo Gates is success on this head, so the ledger row still matches the gate.

Is notify_cleared to role:owner a correct fix? Yes, read end to end:

  • The node side. role:owner is not address-shaped: mailedAddressOf returns undefined for a role:/team:/owner_of: prefix, so the entry never joins addressed or computed, refuseUndeliverableAddresses returns undefined, and the node reaches emit with audience: ['role:owner'], channels: ['inbox']. The run's organization is context.tenantId, else organizationOfRecordAbout on the invoice, else none (the engine's single rule then owns the event).
  • The resolver side. RecipientResolver.resolveOne routes role: to resolveRole, which reads sys_member where role = 'owner', scoped to organization_id only when the emit carries one, with fields: ['user_id'] under the fan-out system context, and returns the distinct user ids. Org-scoped when the run names its tenant; unscoped otherwise, which on a single install is the same set.
  • A dev boot really has that member. AuthPlugin.maybeSeedDevAdmin runs on kernel:ready under NODE_ENV === 'development' (isDevAdminSeedArmed) and signs up admin@objectos.ai (devSeedAdminEmail's default) through better-auth's own signUpEmail, then stamps email_verified. That sys_user insert is a isDefaultOrganizationBootstrapTrigger, and ensureDefaultOrganization also runs on kernel:ready: it finds the platform admin by the config anchor (OS_PLATFORM_OWNER_EMAIL, verified row) or, under a non-walled posture, by the oldest admin_full_access grant owned by the Default Organization (first-user promotion writes it, per the file's own §2 step 5), and when that admin has no membership inserts sys_member { organization_id: default, user_id: admin, role: 'owner' }; a reconciled member row is promoted to owner when the Default Organization has none. The showcase already relies on exactly that row: seed-approval-demo.ts (kernel:bootstrapped) reads sys_member { user_id: admin, role: 'owner' } to learn the organization, assigns the admin manager, finance, legal and exec positions, provisions the two personas, and opens showcase_invoice_signoff on the first sent seeded invoice (all twelve seeded invoices are owned by ada@, linus@ or grace@example.com; none is a provisioned user). So on a dev boot the admin is the Default Organization's owner, holds both unanimous slots, and when the admin approves, notify_cleared resolves role:owner to the admin's own id and the inbox row lands in a readable inbox. The INV-1010 path is the one the new test walks (below); the demo's own boot-time request behaves the same because it opens on a seeded sent invoice with the same owner population.
  • A non-dev boot. There is no dev admin, so whether an owner member exists depends on the deployment: a verified OS_PLATFORM_OWNER_EMAIL account or (non-walled) a promoted first user is bound as owner by the same helper, and role:owner then delivers to them. With no owner member at all, resolveRole returns [], emit logs resolved to 0 recipients at warn and returns delivered: 0, and the notify node returns success: true with selected: 1, acted: 0, the zero-dispatch shape the broken-sweep filter is built to read. That is neither a delivery nor a loud failure. It is the standing contract of every role: audience in the repo (the previous record accepted alert_exec on the same terms), it writes no junk row, and it is not the defect this card fixes, so it is a disclosure item, not a blocker (③ F9). Under a walled posture with no config anchor the grant anchor is retired and the bind never happens, which is the case to name in the changeset.

Is the new test a real pin, and is it red on the old flow? It is a real pin. bootShowcaseApprovals boots a real ObjectKernel with ObjectQL on an in-memory SqlDriver, the automation, approvals and messaging services, installs the Default Organization through the testkit and then installDirectory: sys_user rows for the admin and Mei and a sys_member row mem_admin_owner with role: 'owner' on org_default, all through the driver before any run (data at rest). seedInvoice inserts the invoice with name, owner and status read off the app's own ShowcaseSeedData row for INV-1010 (grace@example.com, sent), so the fixture cannot drift from the seed silently. The assertions are the ones that matter: the run parks on the approval, both slots are decided, the run ends completed, notify_cleared stepped with status === 'success' and flag_held did not, exactly one Invoice cleared inbox row exists, sys_user holds no row for invoice.owner (the premise is asserted, not assumed), and notice.user_id === 'usr_admin'. On the old flow (recipients: [{ cel: has(record.owner) ? record.owner : null }]) the same boot evaluates to grace@example.com, resolveRecipients files it under addressed with computed provenance, refuseUndeliverableAddresses looks sys_user up by that email against a directory holding only the admin and Mei, finds nothing and fails the node, so the resume fails at notify_cleared and run.status is not completed: red by code reading, and the report says red at d0e593bb (test only, flow unchanged) with RESUME_FAILED at notify_cleared. The sweep test's project owner moved from Grace to Mei, a provisioned persona, so that case now mirrors the seed (showcase_project.owner seeds are ADA, ADMIN, MEI, which resolve to auditor.demo@, admin@objectos.ai and phone.demo@, the three PROVISIONED_USER_EMAILS). One limit, stated and acceptable: the test seeds the owner member itself and does not boot plugin-auth, so "the dev admin is the owner on a dev boot" is read in ensure-default-organization.ts and pinned by plugin-auth's own tests (ensure-default-organization.test.ts, default-org-bootstrap-once.test.ts, not re-read here), not by this file.

Does the rest of the showcase still hold under B? Read against the flows at the head: the only notify producer over showcase_invoice was notify_cleared, now moved. The remaining inbox nodes over record fields read record.assignee (tasks) and record.owner (projects), whose seeds are the three provisioned addresses, pinned by inert-wirings.test.ts §5 ("no seeded recipient value is an email the app never provisions"); ownerId (showcase_notify_owner) is fed the project owner by its caller, the same population. task.owner and task.watcher inside the loop demos read fields showcase_task does not declare (task.object.ts has assignee only), so they evaluate to null and are dropped, never to an address: B does not move them. The four admin@objectos.ai nodes are as the changeset now says (F6). alert_exec as before.

A consequence the delta leaves behind, cosmetic. With notify_cleared on role:owner, §5's recipientFieldsByObject derives no field for showcase_invoice, so KNOWN_EXEMPT = {'showcase_invoice.owner'} is now never consulted and its comment ("the ONE recipient field left addressed to non-users") describes a producer that no longer exists; demo-personas.ts likewise says showcase_invoice_lifecycle sends to {record.owner}, a flow not in the repo. The guard's non-vacuity assertions (showcase_task.assignee, showcase_project.owner) still hold, so nothing is weakened; the stale text is a tidy-up (③ F10).

② Semver level

  • Changeset: @objectstack/service-automation patch, title with !, bold BREAKING (run time) line, line-initial Clause-②: no (narrowing: ...) and Metadata-protocol: none, one ADR-0087 marker not-required (no-migration-prescription). Unchanged from the previous record's reading: a non-protocol run-time refusal of a value the contract still accepts ships patch with its banner, the precedent being PR fix(security)!: a hook that runs as system and an action body write no identity assignment #25954. Check Changeset is success on the head. Level right.
  • Only service-automation is named: @objectstack/example-showcase is "private": true, so the showcase flow and test changes owe no changeset line. Right.
  • PR body Clause-②: no (narrowing: ...) line: present, line-initial, names the computed arm (B). Narrowing face, CONTRACT_REVIEW_TIER, this record's tier.
  • The changeset's substance under B is now accurate: the "Who moves" paragraph no longer claims the invoice owners are provisioned; it names alert_exec, notify_cleared with the ADR-0055 reason, the provisioned project/task owners, and the four admin nodes with their dev / non-dev behaviour. The one sentence it does not carry is the no-owner-member case (F9).

③ Boundary flags

  • F1 (previous record, blocking): discharged. notify_cleared moved off record.owner to role:owner with a source comment that states the ADR-0055 reason; approval-resume-relation-expand.test.ts approves the app's own INV-1010 seed row, asserts its owner holds no account and that the notice reaches the admin; and the changeset's "Who moves" is rewritten so the sentence that was false is gone and the seeded demo is named as a mover. The dev's alternative, provisioning the three owners, was rightly not taken: showcase-invoice-seed-isolation.dogfood.test.ts signs UP as ada@ and linus@example.com and pins grace@ as a seeded owner, and a provisioned user refuses that sign-up.
  • F6 (previous record, disclosure): discharged. The four admin@objectos.ai inbox nodes (digest, log_fail, notify_done, report) are named in the changeset with the dev-boot (deliver) and non-dev (fail loud) outcomes.
  • F5: 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, out of this PR, as the brief says. The report's out_of_scope_findings now carries it with a carrier.
  • F7 (dogfood, re-read): the three Dogfood Regression Gate shards are skipped on this head because ci.yml runs them only when github.event_name is not pull_request/merge_group (the full battery lives on main's schedule, push and dispatch runs); the aggregate Dogfood Regression Gate: success is the zero-attestation conclusion the workflow documents. So no PR check-run exercises the approval, as before. The updated report says the local battery was re-run at 45c95ee3 with the service-automation dist rebuilt under B (296 files, no failures) and also at d352c68, which answers the previous F7 (not re-run on the B commits). The isolation dogfood test pins owners and does not approve, so the flow change does not touch it.
  • F8 (branch): unchanged, claude/project-thread-hdpxcs with claim 6408881630 as the identity record; The card this PR closes must claim this branch is success.
  • F9 (new, disclosure, not blocking): on a boot with no owner member of the run's organization (a walled posture with no OS_PLATFORM_OWNER_EMAIL account, or any install where the bind has not landed) role:owner resolves to nobody and the node reports success with delivered: 0, the messaging zero-dispatch shape, not the loud failure this PR adopts for addresses. That is the role: audience's existing contract and applies to alert_exec identically; the changeset should say it in one sentence beside "the Default Organization's admin on a dev boot" so an operator reading CHANGELOG.md is not told the demo now fails loud where it in fact reports a zero.
  • F10 (new, cosmetic): inert-wirings.test.ts §5 KNOWN_EXEMPT and its comment, and the demo-personas.ts paragraph naming showcase_invoice_lifecycle, describe a showcase_invoice.owner producer that no longer exists after this delta. Not wrong at run time (the exemption is simply unreached); a follow-up tidy, or a line in this PR.
  • PR body lag (not a FAIL reason): the body's table and "What changed" bullets still describe alert_exec as the only showcase mover and the "Tests" and "Verification" sections name only the written-address suite at 47c6ea05; nothing names notify_cleared, the INV-1010 approval test, or the d352c68a/55f6fc27 E5 and showcase edits beyond the table's last row. The report says the seat holds the body edits; the body should be brought to the code before merge.
  • Report deviations (B and F1 as two commits each): a push-history fact, no rule in the inputs forbids it, and the head is what is judged. Accepted.
  • The PR is a draft and not governed-surface; nothing here is a seat act. No GitHub write, no local run, no checkout was made for this record.

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

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Head 45c95ee3ad, report 6411976679 (on #25758, updated), claim 6408881630, contract review 6412157468 (PASS, narrowing face; supersedes the FAIL 6411272864 on 55f6fc27).

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 26, 2026 09:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit b0384f3 Oct 26, 2026
58 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-hdpxcs branch October 26, 2026 09:18
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/l tests tooling

Projects

None yet

2 participants