Repository navigation
feat(service-messaging,platform-objects): fan-out consults per-tenant channel availability and writes no delivery row for a channel with no transport (#17732) - #18041
Conversation
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
… channel availability Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
….suppressed_channels Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 7 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 9140b6676794870d2e9b7135d3ab59d540562beb && git checkout 9140b6676794870d2e9b7135d3ab59d540562beb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c185d087b61df5afe54bbfffc0783e6b2378bf14 684df79a9e28e1685f5d085263420d044b97880c && git checkout -B drift-repro c185d087b61df5afe54bbfffc0783e6b2378bf14 && git merge --no-ff 684df79a9e28e1685f5d085263420d044b97880c
node scripts/docs-audit/affected-docs.mjs --json c185d087b61df5afe54bbfffc0783e6b2378bf14
|
…hen it carries information An insert names its columns. Naming the new column on every emit made every emit depend on every sys_notification schema already carrying it, so a stack whose object predates it answered INVALID_FIELD and lost the notification -- to record that nothing was suppressed. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…tside this package's lib Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead reviewed: 🔴 The headline: this delivery FALSIFIED its own card's premise — verified in-seat, and now filed as #18050#17732 states that fan-out "only checks that the channel is REGISTERED". Re-measured on this head, ⛔ not read off the report:
⇒ on the outbox path an unregistered channel gets one delivery row per recipient, and the dispatcher dead-letters each on attempt one ( ✅ The delivery was right to leave it on its existing path and pin the boundary rather than widen. ① Clause-② —
|
| reading | result |
|---|---|
merge-base 1e20f816e, clean detached worktree, none of its commits |
134 files / 1581 tests, 0 failed |
branch e57ee063c |
4 failed (CI run 34763053926) |
branch 684df79a9 |
134 files / 1581 tests, 0 failed |
suppressed_channels in the merge-base tree |
0 occurrences (branch: 13), positive control dedup_key = 44 files |
⭐ "A failure whose cause is a string absent from origin/main cannot be pre-existing." That is a cleaner attribution argument than the three-reading sandwich alone.
⑤ The Object.hasOwn round — it answered the question, ⛔ it did not just make the red go away
Reproduced first (exit 2, TS2550 at :188 and :356), fixed to Object.prototype.hasOwnProperty.call — the spelling 151 files in this repo already use — and ⛔ no tsconfig touched. It then answered the presence question this seat put to it: the call site needs "was the key NAMED on the insert at all" kept distinct from "named with an empty value"; hasOwnProperty.call keeps it, in would answer for inherited keys and !== undefined would conflate them; the receiver is the plain object literal writeEvent builds.
⑥ The file surface — this seat's under-declaration, and the bundles verified
7 files under platform-objects/src/apps/translations/*.generated.ts sit outside the surface my claim declared. ⛔ That is this seat's error (corrected on the card, 5653918230), not a breach. The open question I left there is now answered by measurement: 22 added lines, 0 removed, and every one is either the new field's label/help block or its source-hashes pair — nothing else drifted, no other package, no other agent's work. ⇒ pure generator output; AGENTS.md's ban on hand-editing generated structure is intact. (All four locales carry the English string, which is what the generator seeds; translated values may be authored later.)
⚠️ A defect in MY dispatch order, named by the delivery rather than silently absorbed
My order told it to run the full Lint & Repo Gates step sequence from lint.yml locally. The standing os-dev contract forbids enumerating that job's check:* steps locally, says the standing contract wins, and requires the conflict to be reported. ✅ It reported it instead of quietly picking one, and resolved it by running the two subsets that are its own — 62 mechanically derived families plus 33 more lint.yml names explicitly = 95 real verdicts, each with its own captured exit code, 0 non-zero, plus repo-wide pnpm lint in full. ⛔ No step inferred from the one before it.
⇒ the instruction is wrong and this seat stops issuing it. ⭐ Four gates first answered exit 3, and none was read as a pass — check:i18n then answered exit 1, a real finding (the bundles above), which is precisely why exit 3 is never a pass.
Verdict: PASS at 684df79a9
packages/cli nor scripts/engine-double-contract.pinned.json, so it is outside the #18022/#18046 serial relay and was never blocked by #18032 — it can land independently.
Generated by Claude Code
…hannel instead of writing a delivery row that can only dead-letter (objectstack-ai#18081) Part of objectstack-ai#18050 Clause-②: no Re-derived from the delivered diff, with the control, below.⚠️ **Read this first: the card has two halves and this PR lands ONE of them.** The dispatch asked for a closing keyword; the body deliberately does not carry one, because fork 2 is answered but not implemented. The PM should close the card by hand once it has read the fork-2 measurement below, or re-dispatch that half. Everything in the "What was NOT done" section is a measurement, not a shortfall of effort. ## What was wrong `MessagingService.emit()` has two fan-out paths. The inline P0 path (`fanOut`) has always refused a channel nobody registered: no `send()`, one failed `DeliveryOutcome` per `(recipient × channel)`, `error: channel '(id)' not registered`. The durable P1 path (`enqueueDeliveries`) had **no registration check at all** — its two loops contained no `channels.get`, no `channels.has`, no refusal — so it wrote one `sys_notification_delivery` row per recipient, and `NotificationDispatcher` dead-lettered every one of them on attempt **one** (`processRow` and `processDigestGroup` both ack `dead: true` the moment `getChannel()` answers nothing). That is objectstack-ai#17732's reported symptom: a row that exists only to die. Ruling `5644350987` structurally cannot reach it — `isAvailable()` is a member of a channel IMPLEMENTATION, and an unregistered channel has none to ask. ## What this PR does — fork 1, the "refuse" side `enqueueDeliveries` now checks registration before it enqueues. An unregistered channel gets **no row** and the caller gets the **same failed `DeliveryOutcome` the inline path already produced**, so "nothing was sent and here is why" reads identically whichever path served the emit. `EmitResult.failed` counts them; `enqueued` and `delivered` do not, so no summary can claim work that never existed. The refusal is logged **once per channel per emit**, carrying the number of rows it refused and the remedy — not once per recipient. The durable path is the high-volume one: a 500-recipient audience on one missing channel must not print 500 identical lines, and the count is what sizes the misconfiguration. ### ⛔ Why it is NOT recorded in `sys_notification.suppressed_channels` `CHANNEL_UNAVAILABLE_REASONS` is documented on the seam as *"Why a channel is not available **for a tenant**"* — every value is a column value an operator filters and reports on. An unregistered channel is a **composition** fact: identical for every tenant in the process, and fixed by mounting the channel, not by configuring the tenant. Folding it in would make a per-tenant report assert a deployment-wide misconfiguration. It is also the fence objectstack-ai#18041 settled, from the other side. Recording it would need a new literal in that closed set, which `channel-availability.test.ts` holds byte-equal to the copy inlined on `sys-notification.object.ts` — i.e. an edit in `packages/platform-objects`, which is `domain:engine` and not this card's. Both reasons point the same way, and the suppression key stays written **only when something was actually suppressed**, so the common path's column set is unchanged (pinned again here, by enumeration). ## What was NOT done — fork 2, and why. Two measurements. **1. The card's stated mechanism for fork 2 is falsified.** The card reads `messaging-service-plugin.ts:261-268` as AGENTS.md's "Startup registry reads" three-part shape, on the grounds that *"an email service that registers later never gets its channel"*. Measured on this tree: - `ObjectKernel.use()` throws `Cannot register plugins after bootstrap has started` whenever `state !== 'idle'` (`packages/core/src/kernel.ts:240-243`), so no plugin joins the composition after boot begins. - Both providers register their service in `init()`, not later: `plugin-email/src/email-plugin.ts:430` and `service-sms/src/sms-plugin.ts:214`. - `kernel-base.ts:291` states the contract directly: `kernel:ready` is *"the only correct moment for a plugin to assert that the preconditions it declared were actually met (the registries are still filling during `init()`)"* — i.e. the service registry is no longer filling at `kernel:ready`. ⇒ Part 1 of the three-part shape ("a read of a registry that is still filling") does not hold at that call site, so the rule does not reach it. What *is* live is narrower and does not need a registry argument: a deployment with no email plugin simply has no `email` channel, and its flows notifying on `['inbox','email']` take the path this PR just fixed. **2. The obvious cure for fork 2 is unsafe until a parked defect is fixed first.** Cure 1 in that section ("resolve where it is used") maps cleanly here: register the email channel unconditionally and let `isAvailable()` — which already reads `opts.getEmail()` live at `email-channel.ts:238` — answer `transport_not_configured`. That would convert this whole class into ruling A's suppression shape, with the audit trail, and is clearly the better end state. But it also **widens the reachability of the defect the card parked**: - `email-channel.ts:252` returns `{ ok: true }` when no email service is present (*"capability not installed — no-op"*), and `sms-channel.ts:151` does the same. The SMS channel has no `isAvailable` at all. - Today, with no email service, the channel is absent ⇒ the dispatcher dead-letters the row: wrong, but **loud**. - Register it unconditionally and the dispatcher instead calls `send()` ⇒ `{ ok: true }` ⇒ the delivery row is acked **sent, with nothing sent**. That is a durability lie and a strictly worse shape than the one being fixed. ⇒ Fork 2 needs `send()` to stop reporting success for an absent transport first — which is a retry-semantics change (`classifyError`, permanent vs retryable) with its own pins, and the card explicitly parked it. Routing that is the PM's. ⛔ No unconditional registration in this PR. ## Clause-② — re-derived from the DELIVERED diff, with a discriminating control The delivered diff adds **no published declaration and no accepted value**. Only one changed file ships at all (`files: ["dist","README.md","CHANGELOG.md"]`; the two test files and the changeset never leave the repo). In that file, every added non-comment line lives inside the body of a **private** method, and TypeScript emits private members with no signature: ``` packages/services/service-messaging/dist/index.d.ts:2048 private enqueueDeliveries; ``` **The probe, and the control that proves the probe can see a real export** — run against the freshly built `dist/index.d.ts`: ``` CONTROL (known-published exports, must be non-zero) CHANNEL_UNAVAILABLE_REASONS 5 hits ChannelSuppression 3 hits DeliveryOutcome 5 hits EmitResult 3 hits MessagingChannel 17 hits PROBE (identifiers this diff adds) unregistered-channel 0 hits (d.ts) 0 hits (index.js) channel_not_registered 0 hits (d.ts) 0 hits (index.js) ``` The word `refused` does appear 10× in the published `.d.ts` — 9 of them predate this branch and the 10th is the new doc comment; none is a declaration (`grep -E '^\s*(export|declare|type|interface|const|function|class).*refused'` is empty). And the negative is about the **accept set**, not about reach: the new behaviour does ship (`dist/index.js` carries the new warn text, 1 hit), which is what makes the zeros above informative rather than a probe that simply cannot see anything. ⇒ `Clause-②: no`. No member was added to a published interface, no value was added to a published enum, no key was added to a published payload. A `patch` changeset is therefore the correct grade, not the `minor` a `yes` would require. ## Recovery round — the `Test Core (6/6)` red, its root cause, and the fix The previous push left `Test Core (6/6)` failing. The shard log shows `check-test-completeness: @objectstack/plugin-auth was scheduled but never reached` and the same for `@objectstack/downstream-contract` — 1 of 3 scheduled packages reported, 2 never reached.⚠️ That "never reached" line is a CONSEQUENCE, not the cause. The shard runs turbo, turbo stops on the first failing task, and the two packages behind the failing one never got to run. ⛔ It is not a flake and nothing was skipped, disabled or quarantined to clear it. **Root cause — a cross-package pin this branch's own producer change falsified.** ``` FAIL packages/services/service-automation/src/builtin/notify-delivery-outcome.integration.test.ts > notify run summary vs. the durable delivery record (objectstack-ai#7747) > does not report a countable act for a delivery that dead-letters on an unregistered channel AssertionError: expected [] to have a length of 1 but got +0 (line 122) ``` That file is on `origin/main` (landed by objectstack-ai#7875 for card objectstack-ai#7747) and this branch never touched it. Its first case boots WITHOUT `push` registered and asserted, as its scenario setup, that the durable fan-out wrote one delivery row and the dispatcher dead-lettered it: ```ts expect(rows).toHaveLength(1); expect(rows[0].channel).toBe('push'); expect(rows[0].status).toBe('dead'); ``` Those three lines describe **exactly the defect objectstack-ai#18050 filed**. This PR removes the row, so the pin that recorded the row had to move with the producer. **Why the inherited push did not see it.** The producer package's own suite is green (460/460) and always was — the contradicting pin lives in a CONSUMER package, and `service-automation` resolves `@objectstack/service-messaging` through `dist/` (its vitest aliases only `@objectstack/platform-objects`), so nothing in the producer's own lane could surface it. **The fix — re-pinned, ⛔ not relaxed.** `notify-delivery-outcome.integration.test.ts` now asserts the new producer contract: | | before | after | |---|---|---| | outbox after `dispatcher.tick()` | 1 row, `status: 'dead'` | **0 rows** | | run summary | `acted: 0, unmeasured: 1` | `selected: 1, acted: 0, unmeasured: 0` | | the objectstack-ai#7747 invariant `acted <= non-dead rows` | bound was 0-non-dead | bound is now **0 rows at all** | The `unmeasured` 1 -> 0 move is the point, not a relaxation. `unmeasuredEffect` means "the count is unknown because the dispatcher decides later"; since this PR there is no later — the refusal is synchronous, so the count is KNOWN and it is zero. That is the reading `notify-node.ts` states in its own words: > ⛔ The fix is NOT to report the zero as `unmeasuredEffect`. That flag means > "the count is unknown", and this count is known and it is zero; claiming > otherwise would take the run OUT of the broken-sweep filter > (`selected > 0 AND acted = 0 AND unmeasured = 0`) — the platform's own alarm > for a green-but-inert sweep — on precisely the run that should be inside it. So the durable path now lands where the inline path already was: the fourth case in that same file asserts this identical triple and calls it "correctly eligible for the broken-sweep alert". Making the two fan-out paths agree is what this card set out to do, and the consumer pin is where that agreement becomes observable. The tick is deliberately kept in the updated case: it proves nothing APPEARS later either, which is strictly stronger than the old "a row exists and is dead". ## Independent judgement of the inherited work The inherited diff was re-read and judged rather than extended: - **The `refuse` side of fork 1 is right, and is kept.** The registration check mirrors the inline path's existing one, the failed `DeliveryOutcome` is the same shape, and `notify-node.ts`'s own stated design is what makes the resulting `acted: 0, unmeasured: 0` the correct answer rather than a loss of signal. ⛔ Nothing about the `suppressed_channels` reasoning was reversed. - **One real gap, now closed:** the behaviour change has cross-package consumers and only the producer package was run. The consumer set was enumerated from the manifests (`cli`, `dogfood`, `example-showcase`, `plugin-auth`, `plugin-webhooks`, `runtime`, `service-automation`) and the affected ones were run — see Evidence. - **`scripts/engine-double-contract.pinned.json` is NOT in this diff** (0 hits over the whole branch range), so this card does not join the live serial relay. `check:engine-double-contract` exits 0. ## Evidence — this round, measured on `a3950e4fa`⚠️ Every reading below was taken on a BUILT closure. `service-automation` consumes `service-messaging` through `dist/`, so a run on an unbuilt tree measures nothing. Closure build: `pnpm --filter '@objectstack/service-automation^...' build` :: `VERDICT command-exit 0`. **Reproduction, then the fix** ``` before vitest run src/builtin/notify-delivery-outcome.integration.test.ts Test Files 1 failed (1) · Tests 1 failed | 3 passed (4) AssertionError: expected [] to have a length of 1 but got +0 after Test Files 1 passed (1) · Tests 4 passed (4) ``` **Affected packages, full suites** — `VERDICT command-exit 0` | package | files | tests | |---|--:|--:| | `@objectstack/service-automation` | 134 | **1581 passed** (was 1580 passed / 1 failed) | | `@objectstack/service-messaging` | 43 | 460 passed | **The two packages the aborted shard never reached** — run here because a shard that stops early cannot be read as "the rest passed". `VERDICT command-exit 0`: | package | files | tests | |---|--:|--:| | `@objectstack/plugin-auth` (also a direct consumer) | 110 | 2340 passed | | `@objectstack/downstream-contract` | 3 | 31 passed | **Typecheck** — `VERDICT command-exit 0` for both affected packages. `check:test-typecheck` reports service-automation's test layer compiles under `tsconfig.test.json`, 0 files / 0 errors, and `tsc -p tsconfig.test.json --listFiles` puts the edited file in the program (1 hit) — so the green is attributable, not the "typecheck excludes `*.test.ts`" false reading. **Reverse verification (ablation)** — direction predicted **RED**, observed **RED**, and it fails on the NEW assertion, so the updated pin is discriminating rather than vacuous. The guard block was deleted from the committed producer source, the producer was REBUILT (the consumer reads `dist/`), and the mutation was proven to have reached the artifact before the colour was read: ``` anchor before mutation grep -c 'if (!this.channels.has(channel))' = 1 after mutation = 0 on-disk blob 0b03bae9d (HEAD blob 27c346e) preflight --absent ✓ marker absent from all 6 built files ABLATED RUN Tests 1 failed | 3 passed (4) AssertionError: expected [ { …(18) } ] to have a length of +0 but got 1 ^ line 144: expect(rows).toHaveLength(0) <- the NEW assertion restore on-disk blob 27c346e == HEAD blob; git diff HEAD empty preflight (present) ✓ marker present in 2 built files ✓ tree: working tree clean against HEAD RESTORED RUN Tests 4 passed (4) ``` The script installed a `trap` on EXIT, INT and TERM calling a restore function, restore is proven by STATE (blob equality plus a whole-tree `git status --porcelain`), never by an exit code. **Gates — four numbers, reconciled against `scripts/pm/dispatch-gates.mjs --commands`** Derived on this tree with `--repo objectstack-ai/objectstack` asserted: | | | |---|---| | derived | **62** | | run | **13** | | NOT MEASURED | **0** | | UNRUN | **49** | ⛔ The 49 are UNRUN, **not passed**. They are the `Lint & Repo Gates` farm, which the standing os-dev contract reserves for CI rather than enumerating locally. The 13 run here are the families this diff actually implicates, each read from the gate's own verdict line with the exit code captured before any pipe: ``` exit=0 check:nul-bytes exit=0 check-closing-keyword-parity (+ --self-test) exit=0 check:engine-double-contract exit=0 check-empty-changeset --base origin/main exit=0 check:test-source-alias exit=0 check-adr-0087-registration --base origin/main exit=0 check:cross-package-test-inputs exit=0 check-changeset-no-major --base origin/main ``` plus the two affected packages' `test` and `typecheck` tasks counted above. A control-character self-scan over all five files in the branch range (`grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'`) matched nothing.⚠️ **NOT MEASURED, stated as such:** `dispatch-gates.mjs` reports this tree is 2 commits behind `origin/main` (`57343f761`), with `scripts/pm/check-skill-line-ratchet.mjs` changed in that range. This diff touches no `skills/**`, so no family is added by it, but the derivation above is a reading about `a3950e4fa` and not about the merge the queue will build. ## Acceptance notes — observed, not filed, not fixed here - `fanOut` logs its "not registered" warn once per `(recipient × channel)` pair rather than once per channel per emit. Same noise shape this PR avoided on the durable path; the inline path is the low-volume one, so it is left alone rather than widened into scope. - `email-channel.ts:252` / `sms-channel.ts:151` returning `{ ok: true }` for an absent transport is already recorded on objectstack-ai#18050's body. It is not filed separately here so that the card's own record stays the single home for it — which is the second reason this PR does not carry a closing keyword. - `sms-channel.ts` implements no `isAvailable`, so the SMS half of ruling A is unrealised. In-lane, but it only becomes useful together with fork 2, so it belongs to that routing. - The consumer pin this round repaired lives in `service-automation`, not in this card's landing package. It is in-lane (`domain:services`) and was owed by this diff's own behaviour change, so it is fixed here rather than filed. - `sms-channel.ts` still implements no `isAvailable`, and `email-channel.ts:252` / `sms-channel.ts:151` still answer `{ ok: true }` for an absent transport. Both are already recorded on objectstack-ai#18050's body; noted, not filed, so the card stays the single home for them. - This branch is 2 commits behind `origin/main`. No merge was taken in this round: the queue rebuilds the PR as merged and re-runs the required contexts on that generation, which is the reading that decides. Authored by Claude Code in session `session_01URLHobLUJB9K1ABV6ofdjj` (recovery round; the first round was lost to a container restart). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… at kernel:ready (objectstack-ai#18272) Part of objectstack-ai#18050 Clause-②: yes This delivers the card's SECOND half — registration read as a one-shot verdict. The first half (the outbox fan-out writing a delivery row for a channel nobody registered) landed in objectstack-ai#18081 and is on `main` today, so it is not what this PR changes. See "Premise re-verified" below: that half of the card's stated premise has DRIFTED and no longer reproduces. ## Premise re-verified on today's `origin/main`, driven rather than read Re-derived by symbol (every line number on the card is a stale anchor), at merge base `e592b4b6b`: | the card says | measured today | |---|---| | inline P0 `fanOut` checks registration | holds — `getChannel(channelId)`, refusing one line down | | outbox P1 `enqueueDeliveries` has NO check | **DRIFTED** — objectstack-ai#18081 added one; the loop refuses and counts | | an unregistered channel gets one row per recipient, dead-lettered on attempt one | **no longer reproduces** | | registration is a ONE-SHOT VERDICT (`if (getEmail())` at `kernel:ready`) | holds, unchanged — this PR's target | Both directions were driven end to end through a real `MemoryNotificationOutbox` and a real `NotificationDispatcher.tick()`, not concluded from the shape of the loops. A temporary probe emitted on `['inbox','email']` with `email` never registered: ``` leg A — today's main, guard in place rows_written_for_unregistered_channel=0 after_one_tick=[] emit_counts={"enqueued":2,"failed":2,"suppressed":[]} leg B — the same tree with the landed guard REMOVED (the shape the card describes) rows_written_for_unregistered_channel=2 after_one_tick=[{"status":"dead","attempts":1,"error":"channel 'email' not registered"}, {"status":"dead","attempts":1,"error":"channel 'email' not registered"}] emit_counts={"enqueued":4,"failed":0,"suppressed":[]} ``` So the card's reported symptom is real and is already cured on `main`; the probe file was deleted after both legs ran (restore proven by blob identity, see Evidence). ## What was wrong with the half this PR takes `messaging-service-plugin.ts` mounted the email and SMS channels inside a `kernel:ready` hook, behind `if (getEmail())` / `if (getSms())`. The comment above that guard reasoned "the dispatcher looks channels up dynamically, so registering after it is fine" — true of the dispatcher, and contradicted by the guard beneath it: the `if` ran exactly once and nothing revisited it. A transport that registered later in the same boot — a plugin ordered after this one registering from its own `kernel:ready` handler, `kernel:bootstrapped`, `kernel:listening`, or a runtime mount — never got its channel, and every `notify` naming it was refused as "not registered" for the life of the process, with the transport sitting in the registry. That is the three-part shape AGENTS.md's "Startup registry reads" section names: a read of a still-filling registry, a terminal conclusion from absence, and the conclusion recorded — here as a non-registration nothing revisits. `pnpm check:startup-registry-verdict` is green on it both before and after, because the AST rule reads a recorded verdict and a mount that never happens leaves nothing for it to read. ## The change `MessagingService.registerChannelProvider(id, resolve)` mounts a channel that is resolved on EVERY lookup; `getChannel` asks the provider, and `getRegisteredChannels` lists only what resolves right now. The plugin mounts both channels through it, memoising the channel OBJECT (so it keeps its identity and its template-store handle) and never the transport's PRESENCE — that re-read is the whole fix. This is AGENTS.md's first cure, in the shape `createLazyCounterStore` (plugin-auth) established: nothing is resolved at start, so plugin order decides nothing. The dispatcher reads the same lookup through `ChannelRegistry.getChannel`, so the old guard's claim is finally true end to end rather than contradicted by the code beneath it. ## What this deliberately does NOT move ⛔ An unmounted channel is still REFUSED, never suppressed. A composition that never registers the transport behaves exactly as it does today: the channel is not mounted, both fan-out paths refuse it with the same failed `DeliveryOutcome`, no `sys_notification_delivery` row is written, and nothing is recorded in `sys_notification.suppressed_channels` — an absent mount is a COMPOSITION fact, identical for every tenant in the process, and that column answers "why can this TENANT not send" (objectstack-ai#18041's settlement). Whether a MOUNTED channel can send is the separate question `isAvailable` answers, and this PR does not touch it. `channel-availability.test.ts` and `unregistered-channel.test.ts` are unchanged and green: the boundary they pin was held, not rewritten to make room. Both existing plugin pins ("registers the sms channel when an sms service is present" / "does NOT register the sms channel when no sms service exists") are also unchanged and green — this PR changes WHEN the mount is decided, not what it decides. The alternative fork — mounting both channels unconditionally and letting `isAvailable` answer `transport_not_configured` — was rejected for exactly that reason: it would move a deployment-wide composition fact into the per-tenant suppression column, re-opening what objectstack-ai#18041 settled, and (SMS implements no `isAvailable`) would turn an absent SMS transport into a delivery row recorded as SUCCESS with nothing sent. ## Evidence **Suite** (affected package, full): `pnpm --filter @objectstack/service-messaging test` — `Test Files 44 passed (44) · Tests 467 passed (467)`, VERDICT command-exit 0. `pnpm --filter @objectstack/service-messaging typecheck` — VERDICT command-exit 0, and `tsc -p tsconfig.json --listFiles` puts both edited test files in the program (1 hit each), so the green is attributable. **Ablations** — one-off, direction predicted RED and observed RED, each proven to reach disk by anchor counts and restored by STATE (not by an exit code). No `dist` leg is owed: the suite is in-package and vitest resolves these modules from `src`. | mutation | anchor before/after | result | restore | |---|---|---|---| | M1 — the plugin decides the mount ONCE again (`presentOnce`) | 1/0, `presentOnce` 0/2 | `Tests 2 failed, 7 passed` — "expected [ 'inbox' ] to include 'sms'" | on-disk == HEAD blob `4e177e236`, `git diff HEAD` empty, 9 passed after | | M2 — the service MEMOISES the provider answer (the verdict, moved) | 1/0, `resolvedOnce` 0/3 | `Tests 1 failed, 4 passed` — "expected [ 'inbox', 'email' ] to not include 'email'" | on-disk == HEAD blob `428fc9e93`, 5 passed after | | premise probe leg B — the landed objectstack-ai#18081 guard removed | 1/0 | 2 rows written, both `dead` on attempt 1 | on-disk == HEAD blob `428fc9e93`, anchor back to 1 | **Gates — derived 64 / run 20 of them / NOT MEASURED 1 / UNRUN 44** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived after the changeset existed; exit codes captured before any pipe, verdicts read from each gate's own output). 14 of the 15 exit 0: `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:engine-double-contract`, `check:type-check-coverage`, `check-registry-log-declared`, `check-plugin-teardown-shape`, `check-undeclared-dep-imports`, `check-empty-changeset --base origin/main`, `check-changeset-no-major --base origin/main`, `check-adr-0087-registration --base origin/main`, `check-closing-keyword-parity`, `check:published-files`, `check-comment-mask-adoption`. Plus `pnpm check:startup-registry-verdict` (exit 0), run because it owns this defect shape although the derivation does not name it. ⊘ NOT MEASURED: `pnpm check:type-check-debt` exited **3 — PREREQUISITE NOT MET** (it wants the whole-workspace build closure lint.yml builds first). Exit 3 is not a pass and not a finding; nothing was measured. The 44 UNRUN are the Lint and Repo Gates farm, left to CI rather than enumerated locally — see the follow-up section: one of them turned out to be a real finding about this diff, and the count above already includes the gates that round added. **Control characters**: a self-scan over the diff's files matched nothing. ## Clause-②: yes — re-derived from the DELIVERED diff, with controls ⛔ Not from the word `export` and ⛔ not from a bundle grep. Probed through the published entry (`exports["."]` to `dist/index.js` / `dist/index.d.ts`) after building the package: | probe | reading | |---|---| | added `export` lines across the whole diff | **0** — and that is the trap, not the answer | | `src/index.ts` (barrel re-export list) | **unchanged**, 0 lines | | positive control — `MessagingService.prototype.registerChannelProvider` | **function**, and declared on the exported class in `dist/index.d.ts` | | positive control — an already-published member (`registerChannel`) | function — the probe is not new-blind | | negative control — `lazyChannelMount` (plugin-local helper) | `undefined`, and absent from the d.ts | | negative control — the `ChannelProvider` type | not importable (not in the export list) | | negative control — a name that does not exist | `undefined` — the probe can answer no | ⇒ the published surface of an already-published entry grows by one callable member, so the direction is a WIDENING and the honest declaration is `yes`.⚠️ The card's claim comment declares `Clause-②: no` as a prediction; that prediction is falsified by this diff, and correcting the card's declaration and carrying the label on the card side are the seat's acts, not this branch's. `needs:contract-review` is on this PR. ## Not in scope, recorded `email-channel.ts` and `sms-channel.ts` still answer `{ ok: true }` when their transport is absent, so a direct `send()` from the dispatcher can record a delivery as success with nothing sent. The card's body already records it as not this card's fix, and this PR narrows rather than widens its reach: with the mount tracking the transport, fan-out cannot reach an unmounted channel at all. ## Follow-up after review feedback (head `6166e303c`) Two reds were reported on head `661c9b611` and both were mine. Neither was answered by loosening anything. **1. `Check Changeset` — the changeset grade was the side that was wrong.** The PR declares clause ② `yes`, and `.changeset/lazy-messaging-channel-mounts.md` graded `patch`. Under the maintainer ruling of 2026-09-04 (decision batch objectstack-ai#35, on objectstack-ai#15294) a purely additive widening of a published package's public surface takes at least `minor`, and my own Clause-② derivation above is the argument for it: a new callable member on an already-exported class is exactly that widening. Regraded to `minor`, and the entry now names the added API so an upgrading consumer reads what is new. `node scripts/check-changeset-no-major.mjs --base origin/main` driven offline against this PR's own event payload: "✓ LEVEL AXIS: this PR declares clause-② `yes`, and no package whose `packages/**/src/**` it moves is graded `patch`", exit 0. The lockstep cost is real and named rather than dodged: every publishable package is in the `fixed` group, so this minor moves the group's minor — that is the price the ruling sets for the widening, not a reason to under-grade it. **2. `Lint & Repo Gates` / `Slot-lookup ratchet` — a real finding about this diff.** The ratchet is right and my tests were wrong: both plugin tests this PR added wrote `const messaging: any = ctx.getService('messaging')`, which is the erasure shape `slot-lookup/no-any-assignment` bans, and `messaging-service-plugin.test.ts` is grandfathered for its EXISTING sites only — so the file's count grew 2 to 4. Fixed at the source with the spelling this same file already uses for its other lookups (`as MessagingService`). ⛔ The two grandfathered sites were left alone and `scripts/slot-lookup-baseline.json` is untouched: sweeping them is the batch work that owns that ledger, and re-baselining to clear my own red would be the one move this gate exists to prevent. Verdict line after the fix: "✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new… baseline key set verified against e592b4b: no files added", exit 0 (captured before any pipe).⚠️ Worth recording against my own gate report: this red came out of the 49 I had left to CI. The reading was contract-legal, but a diff that adds test code doing service lookups owes `check:slot-lookup` locally, and I did not run it. **Re-verified at `6166e303c`**, after both remedies: closure build, `pnpm --filter @objectstack/service-messaging test` -> `Test Files 44 passed (44) · Tests 467 passed (467)`, and `typecheck` — one chained run, VERDICT command-exit 0. Both ablations re-run at this head because the ratchet fix touched the very file M1's red is read from: M1 still `Tests 2 failed | 7 passed` ("expected [ 'inbox' ] to include 'sms'"), M2 still `Tests 1 failed | 4 passed`, each restored to its HEAD blob with `git diff HEAD` empty. Authored by Claude Code, session `session_01URLHobLUJB9K1ABV6ofdjj`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…not report success (objectstack-ai#18562) Fixes objectstack-ai#18424 Clause-②: no — this PR adds no new key to a published payload and no new error code. It NARROWS the accept set: `send()` withdraws an `{ ok: true }` it was never entitled to give. The refusal token `transport_not_configured` is a **pre-existing** member of `CHANNEL_UNAVAILABLE_REASONS` (`packages/services/service-messaging/src/channel.ts:114` on `origin/main`, negative control 0), and the delivered diff adds **zero** `export` statements (its only `+` line containing the word is a comment). Re-derived from the DELIVERED diff by the dispatching `domain:services` seat (claim `5706661708`), ⛔ not carried forward from the claim-time prediction. `email-channel.send()` and `sms-channel.send()` returned `{ ok: true }` when their transport was not installed — "capability not installed — no-op". The `sys_notification_delivery` row reached `status: 'success'`, nothing went red, no row dead-lettered, and a deployment with an unconfigured email or SMS transport reported every notification as delivered. The same object's `isAvailable()` already answered the identical condition correctly: `{ available: false, reason: 'transport_not_configured' }`. ## The measurement the card assigned, driven both ways The card did not prescribe the fix; it asked whether *transport absent* is the same case as *channel unmounted*, and required that be driven rather than read off the source. It was driven against `origin/main` at `79a046f8`, with the real channels through a real `MessagingService`, a `MemoryNotificationOutbox` and a real `NotificationDispatcher` tick. | leg | wiring | transport | reading | | :-- | :-- | :-- | :-- | | A | plugin (`registerChannelProvider` + `lazyChannelMount`) | absent throughout | `send()` never reached · `channel 'email' not registered` · `failed: 2` · no rows · no suppression | | B | plugin | present at emit, gone before dispatch | rows enqueued, then `status: 'dead'`, `attempts: 1` | | C | direct `registerChannel` (public export) | absent throughout | email suppressed at fan-out · **sms row `status: 'success'` with nothing sent** | | D | direct `registerChannel` (public export) | present at emit, gone before dispatch | **both rows `status: 'success'` with nothing sent** | | E | plugin (control) | present throughout | both deliver | **Answer: not the same case at the fan-out seam; the same case at the `send()` seam.** - At the **fan-out** seam the tree already discriminates them, and the discrimination is load-bearing. Transport absent is a per-tenant configuration fact — SUPPRESSED, recorded on `sys_notification.suppressed_channels`, `failed: 0`. Unmounted is a deployment composition fact — REFUSED, one failed `DeliveryOutcome`, no suppression record. Leg A and leg C show both answers side by side. - At the **`send()`** seam there is no suppression left to record: the delivery row already exists and `SendResult` carries no suppression arm. The only two answers available are the lie `{ ok: true }` and a refusal. - And in the composition that ships, the two conditions are **one predicate**. `lazyChannelMount`'s `resolveTransport` *is* `getEmail` / `getSms` — the very resolver `isAvailable()` answers from and `send()`'s no-op branch tested. Transport absent therefore already means unmounted there (leg A), and already means REFUSED. So `send()` owes the refusal. That lands on the refusal side of objectstack-ai#18041's settlement rather than moving it, which is why the boundary pin is untouched and green. ## What this changes - **`send()` refuses with the reason `isAvailable()` already returns**: `{ ok: false, error: "transport_not_configured: no 'email' service is registered; nothing was sent to 'RECIPIENT'" }`. The token is the declared `CHANNEL_UNAVAILABLE_REASONS` member, held inside that closed set by a type annotation (`const TRANSPORT_NOT_CONFIGURED: ChannelUnavailableReason`) — a token nobody declared would not compile. **No new error code**: no `ERROR_CODE_LEDGER` and no `StandardErrorCode` entry, so `Clause-②: no` stands. - **`classifyError()` grades that refusal `permanent`**, so the row dead-letters on attempt one. Driven, not assumed: leg B is the shipping composition's own answer to this identical condition — `dead`, `attempts: 1`. Grading it `retryable` would make the two compositions end one condition differently and burn the whole ladder against a transport no attempt can install. - **Not a suppression**, for the reason above. ## Verification - `pnpm --filter @objectstack/service-messaging test` — **44 files, 473 tests, all pass**; `typecheck` green in the same invocation. - **R2's boundary pin is untouched and green.** `channel-availability.test.ts` is not in the diff; re-run verbosely with its two siblings: `channel-availability.test.ts` 10/10, `unregistered-channel.test.ts` 5/5, `lazy-channel-mount.test.ts` 5/5. - **Ablation** (the forbidden `{ ok: true }` put back on both channels, proven on disk by blob hash and by before/after occurrence counts, then restored from `HEAD` and proven byte-identical — `git diff HEAD` empty, both blob hashes equal to the `HEAD` blobs): ``` MUTATION CONFIRMED ON DISK: email 7fc96c9 -> 51ea2f7c ; sms 686840a -> 384338f2 × refuses with the SAME declared reason isAvailable() returns AssertionError: expected true to be false × END TO END: one emit, one outbox, one dispatcher tick - the row lands `dead`, not `success` AssertionError: expected 'success' to be 'dead' × refuses with the declared transport_not_configured reason AssertionError: expected true to be false × END TO END: one emit, one outbox, one dispatcher tick - the row lands `dead`, not `success` AssertionError: expected 'success' to be 'dead' Test Files 2 failed (2) Tests 4 failed | 56 passed (60) RESTORED: email=7fc96c96 sms=686840a5 ; git diff HEAD = '' (want empty) ``` The controls stayed green under the same mutation, so the pins discriminate rather than just tracking the run. - **Downstream**, against the built `dist` carrying the change: `@objectstack/service-automation` — 136 files, 1603 tests, plus the 6 integration files that needed the closure built, 31 tests. All green. It is the `notify`-node consumer of this path. - **Gates**: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` against the actual 5-path change set at `a067e667`, each exit code captured to a file before being read, then reconciled with `--ran`: **64 derived, 61 run, 3 NOT-MEASURED, 0 UNRUN**. - The 3 are `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`, each exit **3** = `PREREQUISITE NOT MET` (they read a whole-repo build). Declared to CI, which builds. - `check:cross-package-test-inputs` exited 1 locally, naming `packages/cli/test/init-created-files-summary.e2e.test.ts` walking `packages/spec/dist/` — no path of this diff. Two-leg control, my diff present in both legs: with `packages/spec/dist` on disk it reds; with that gitignored build artifact moved aside it exits **0** (216 files verified restored). `lint.yml` states the `Lint & Repo Gates` job runs `pnpm install` and never builds, so CI never sees that directory. Noted below as a finding rather than carried here. - **Lint, narrowed and the narrowing proven**: `pnpm exec eslint --no-inline-config --format json` over the 4 changed TypeScript files — 4 files linted (count read from the JSON, not asserted), 0 errors, 0 warnings, exit 0. The narrowing is safe because this repo's single `eslint.config.mjs` **never enables type-aware linting for any file** (no `parserOptions.project`, no typed `@typescript-eslint` rules — stated and measured at `eslint.config.mjs:328`), so nothing in this diff can move the verdict on a file it does not touch. Run at `a067e667`, the final commit. ## Acceptance notes Observations outside this card's scope, recorded rather than fixed here. - **`sms-channel` declares no `isAvailable()`.** `email-channel` is the only implementation of that member in the repository, so fan-out cannot suppress `sms` where it can suppress `email` — leg C is that asymmetry. It is immaterial under `MessagingServicePlugin`, where the mount gate refuses first (leg A), and it does not affect this fix: the refusal is graded `permanent`, so a transport-absent sms delivery ends in one dead row rather than a retry ladder. Worth its own card as the unfinished half of objectstack-ai#17732. - **`check:cross-package-test-inputs` reds as a function of local build state**, per the two-leg control above. Anyone who builds `packages/spec` before running the gate chases a finding CI cannot see. - Noted, not filed: the `{ ok: true }` branch is unreachable under the only in-tree composition (`MessagingServicePlugin`), and `createEmailChannel` / `createSmsChannel` have no other construction site in this repository — so the defect's live surface is the public export rather than anything this tree runs. Recorded because it is the reason the fix targets a published contract and not a live code path. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WmBwEiWPff9JZPd5BSGNeH --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17732
Implements the ruling
5644350987(director seat, decision batch #122 item 5, 2026-09-12), routed todomain:servicesthrough triage's cross-domain exception path (5650914775).Clause-②: yesWhat changed
Fan-out asks a channel whether the tenant can send on it before it writes anything.
MessagingChannelgains one OPTIONAL member —isAvailable(ctx, { organizationId })answering{ available: true }or{ available: false, reason }from the closed vocabularyCHANNEL_UNAVAILABLE_REASONS(today:transport_not_configured).emit()consults it once per channel, before the event write — a channel that answers unavailable gets nosys_notification_deliveryrow and nosend()call, on the outbox (P1) and the inline (P0) path.sys_notificationgains one key,suppressed_channels—[{ channel, reason }],NULLwhen nothing was suppressed — written in the same insert that creates the event row, so the feature costs zero additional writes.emailchannel implements it, answering from the transport it was handed. Theinboxchannel implements nothing — which is literally "always available" per the ruling, and doubles as the production instance of the optional-member default.EmitResultgainssuppressedso a two-channel emit that enqueued one row is never indistinguishable from a fan-out bug.The transport-cost measurement (ruling item 3), and what was done about it
Measured, not assumed. Two readings, both on this branch:
email.isAvailable()— service presentemail.isAvailable()— service absentawaita resolved no-opfindOne— the in-process floor of any I/O-shaped answerAnd the call count, driven through the real
emit()with 50 recipients × 2 channels:⇒ The probe is synchronous and in-memory, so it is cheaper than merely awaiting an already-resolved promise, and it runs once per channel per emit — O(channels), never O(deliveries). The three nanosecond figures are within measurement noise of each other; the honest statement is that the probe is not distinguishable from call overhead, and the "present" row reading below the empty-call baseline is that noise, not a speedup.
⛔ No cache was added, and the ruling's condition is why: it says cache if the answer is not cheap, and it is. A cache here would also be wrong. Mail configuration in this tree is the
mailsettings namespace atscope: 'global'(packages/services/service-settings/src/manifests/mail.manifest.ts), materialised into a single in-memoryIEmailTransportthatEmailServicePluginhot-swaps from the settings change bus. A per-tenant-per-tick memo would keep answering "unavailable" straight through the settings save that fixed it.scope: 'global'is deployment-wide. The seam still takes the tenant context so the day mail becomes tenant-scoped, the answer changes inside the channel and no published interface has to move again.The discriminating controls, and what each would have caught
Every one of these is a test that fails against a differently broken implementation, which is why they are pinned rather than assumed.
sys_notification— asserted on one passisAvailablestill gets its delivery row, andsuppressed_channelsisNULLCHANNEL_UNAVAILABLE_REASONSpackages/platform-objectsis a lower layer and cannot import the vocabulary, so the two copies are held equal by an assertion — a comment would notBoth were mine, both are fixed, and neither was a test asserting the behaviour the ruling changed.
1.
Test Core (4/6)—service-automation, 4 tests innotify-zero-delivery-visibility.integration.test.tsThe error text names the cause exactly:
That harness declares
sys_notificationas a fixture whose own comment says it is "exactly the columnsMessagingService.writeEventinserts — a fixture that drifts from the producer fails loudly on the SQL arm". It is a producer-drift detector, and it did its job.⭐ The fix was in the producer, not the test. The first draft named
suppressed_channelson every insert (asnullwhen nothing was suppressed). An insert names its columns, so that made everyemit()in the world depend on everysys_notificationschema already carrying the new column — losing the whole notification to record that nothing was suppressed.writeEventnow adds the key only when there is something to say, so the common path's column set is exactly what it was before this change. Pinned two ways inchannel-availability.test.ts: the key is absent (notnull) on the control path, and the common-path column set is enumerated.⛔ No assertion was retuned. The automation fixture is untouched by this PR, and no test of the ruled behaviour was weakened.
Causation, with the control:
pnpm --filter @objectstack/service-automation test1e20f816e, clean worktree, none of my commitse57ee063c(column named on every insert)684df79a9(column named only when non-empty)And a second, independent control on the cause itself:
suppressed_channelsoccurs 0 times in the merge-base tree and 13 times on this branch, withdedup_keyas the positive control for the samegit grepagainst that same tree (44 files). A failure whose cause is a string that does not exist onorigin/maincannot be pre-existing.2.
Type Check · workspace—Object.hasOwnis outside this package's libTwo sites, both in the test file added by the previous push. Reproduced first (
pnpm --filter @objectstack/service-messaging typecheck→ exit 2,error TS2550atchannel-availability.test.ts:188and:356), then fixed toObject.prototype.hasOwnProperty.call(...)— the spelling this package's siblings already use — then re-run clean (exit 0) with the package suite re-run after it.⛔ No tsconfig was touched: adding a
lib/targetto makeObject.hasOwnresolve would change what the whole package compiles against, which is far outside this card.The presence distinction the call site needs, stated: it asks "was this key NAMED on the insert row at all?", which must stay distinct from "named with an empty value" — that distinction is the entire point of the pin.
hasOwnProperty.callkeeps it;key in objwould also answer for inherited keys and!== undefinedwould conflate the two, so neither was usable. The receiver is the plain object literalwriteEventbuilds — ordinary prototype, no ownhasOwnPropertykey — and the.callform is correct regardless of either hazard.Gates — final, on
684df79a995 gate commands run to a real verdict, 0 non-zero:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack; re-derived after the final commit and byte-identical to the first derivation)..github/workflows/lint.ymland not in that derived set — includingcheck:doc-anchors,check:adr-anchors,check:verify-stand-in,check:entry-guard,check:parse-guard,check:bash32-floor,check:sdui-lockstep,check-sdui-manifest, thecheck:pm-*family,check:durability-log-level,check:startup-registry-verdictand the twoscripts/pm/*.sh --self-tests.Lint & Repo Gatesjob. Running that job's full step list locally is a farm-wide sweep that the dev contract reserves for CI; the two sets above are its mechanically-derived and explicitly-named subsets. Nothing was inferred from the step before it — every command has its own captured exit code — and ⛔ noexit 3was read as a pass:check:i18nplatform-objectsbundles drifted) → regenerated → exit 0check:i18n-coveragecheck:dual-build-cjs-loadscheck:type-check-debtcheck:where-matcheris green (407 matchers, 0 silently-wrong, 0 unjudged, no files added to the baseline). This PR adds no in-memorymatches(row, where)double — its engine double answersinsertandfindonly.Gates as first run
The first pass, on
e57ee063c— superseded by the table above, kept because it is the reading the CI red was measured against:Gate families derived mechanically from the change set (
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack), all 62 run to a real verdict, none inferred from the one before it: 59 green on the first pass. The three non-zero answers were all exit 3 — PREREQUISITE NOT MET, a statement about the tree rather than a finding:check:i18ndist/for the 10-package extract closure)platform-objectsbundles drifted → regenerated → exit 0check:i18n-coveragecheck:dual-build-cjs-loadsdist/)check:type-check-debt⛔ No
exit 3was read as a pass anywhere.Out of the declared file surface — reported, not ridden in
sys_notificationdriftspackages/platform-objects/src/apps/translations/*.generated.ts, whichpnpm check:i18nfails on and whose remedy it names. They were produced by the generator —node scripts/check-i18n-bundles.mjs --write, never by hand: AGENTS.md forbids hand-editing generated structure (translated values may be hand-written, and none was). The regeneration is 22 added lines across 7 files, and the diff contains nothing but the new field — no other package drifted, and nobody else's work was picked up. Same package and same lane as the declared out-of-lane file, so the exception path's disjointness measurement is unaffected; recorded here so the surface can be re-recorded rather than quietly widened.Acceptance notes
Found while verifying the card's own claims against the tree. ⛔ None of it rides in here.
MessagingChannelmember) so no delivery row is written for a channel with no transport? — split from #17611 #17732 says fan-out "only checks that the channel is REGISTERED". That is true of the inline (P0)fanOut, and false of the outbox (P1)enqueueDeliveries, which performs no registration check at all — so a channel named inchannelsthat is not registered gets a delivery row per recipient, andNotificationDispatcherdead-letters it withdead: trueon attempt one. That is the exact "dead-letters on its first attempt" symptom the card opens with, and this ruling does not reach it: the ruling's member is a property of a channel implementation, and an unregistered channel has none to ask. Left on its existing path and pinned as such.emailMessagingChannelis registered onlyif (getEmail())atkernel:ready(messaging-service-plugin.ts:264) — a registry read turned into a permanent registration verdict, the shape AGENTS.md's Startup registry reads section names. An email service that registers later never gets its channel.createEmailChannel().send()returns{ ok: true }when no email service is registered — a delivery row recorded success with nothing sent. Not reachable through fan-out any more once this lands, but still reachable by a directsend().sms-channel.ts. It is untouched here and stays available, as the optional default requires.🤖 Generated with Claude Code
https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Generated by Claude Code