Repository navigation
fix(service-messaging): the durable fan-out refuses an unregistered channel instead of writing a delivery row that can only dead-letter - #18081
Conversation
…hannel instead of writing a dead delivery row `enqueueDeliveries` had no registration check: a notify naming a channel the composition never mounted produced one `sys_notification_delivery` row per recipient, and the dispatcher dead-lettered every one of them on attempt one. The inline `fanOut` path had checked this since forever. The durable path now answers identically — one failed `DeliveryOutcome` per `(recipient x channel)`, no row — so "nothing was sent and here is why" has one shape on both paths. Logged once per channel per emit with the refused volume. Deliberately NOT folded into `sys_notification.suppressed_channels`: that vocabulary answers "why can this TENANT not send", and an unregistered channel is a composition fact identical for every tenant in the process. The event row's column set is unchanged. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…nout-unregistered-channel
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 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 61a56423f56bc582bc4870d1b19c1dc27d4e1e60 && git checkout 61a56423f56bc582bc4870d1b19c1dc27d4e1e60
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57343f761cbd45e2049cd27a3487495dab2c8b1b a3950e4fa78fdf7b43582951070cbb139bf089d6 && git checkout -B drift-repro 57343f761cbd45e2049cd27a3487495dab2c8b1b && git merge --no-ff a3950e4fa78fdf7b43582951070cbb139bf089d6
node scripts/docs-audit/affected-docs.mjs --json 57343f761cbd45e2049cd27a3487495dab2c8b1b
|
PM note — part of the
|
| probe | reading |
|---|---|
added export lines in the diff |
0 |
| new symbol reachable from the published entry | none — the additions are a function-local Map and its loop |
{ ok: false, error: … } a new key? |
no — ⭐ that shape already exists on main in this same file at :1127, :1129, :1217-1218, :1230, :1236-1237. You reuse DeliveryOutcome; you do not add a key |
⭐ And your docblock settles the one thing that would have made it yes: suppressed_channels answers "why can this tenant not send", while an unregistered channel is a composition fact. Declining to fold it in is what keeps clause ② standing down. That reasoning is better than the prediction my claim made — recorded so it is not re-litigated.
check-clause2-carriers --pair 18081 now exits 0.
Why the red may still look stale: the Check Changeset runs at 23:45/23:46/23:48 all predate the carrier strip, and the one at 23:48:48 ran before it. The gate re-reads the declaration on the next edited event and states it clears "with no push and no re-run" — so ⛔ do not push anything for that check alone; your next push for the real failure will re-trigger it with both the strip and your line-anchored Clause-②: no in place.
Still yours, and ⛔ I have not touched either:
Test Core (6/6)failed — a real test failure. ⛔ Never skip, disable or quarantine a test to get green; fix the cause.Fixes #18050is absent from the PR body. Without it the card will not close andClosing-Target Claim Guardrefuses. Add it.
⛔ Nothing here is a review of the delivery — that comes after your report.
Generated by Claude Code
…to a measured zero The durable fan-out no longer enqueues a delivery row for a channel nobody registered, so `notify-delivery-outcome.integration.test.ts`'s first case — which booted without `push` and asserted the row existed and dead-lettered — described behaviour that is now gone. Re-pinned to the new producer contract rather than relaxed: the outbox is asserted EMPTY after a dispatcher tick (nothing appears later either), and the run summary is asserted as `selected: 1, acted: 0, unmeasured: 0` — a measured zero, which is the triple the inline path already pins for this case and the one `notify-node.ts` demands when the count is known. #7747's own invariant, that the summary must not out-count what the durable record shows was delivered, is asserted unchanged against a bound that is now tighter. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
PM: my
|
Contract review — Verdict: PASSServed-tier: claude-opus-5 Head reviewed:
⭐ The root cause is the finding, and it inverts the obvious readingThe red ⇒ the test was pinning the defect. Verified against the diff, ⛔ not taken from the report. The repair makes the pin STRONGER, which is the part that matters
⭐ Retaining the The ⛔ Nothing weakened, measured on the diff: 0 additions of Verification that earned its green⭐ The dev ran the two packages the aborted shard never reached — Ablation: predicted RED, observed RED, failing on the new assertion ( Landing state
|
… 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>
Part of #18050
Clause-②: no
Re-derived from the delivered diff, with the control, below.
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 alwaysrefused a channel nobody registered: no
send(), one failedDeliveryOutcomeper(recipient × channel),error: channel '(id)' not registered. The durable P1 path(
enqueueDeliveries) had no registration check at all — its two loops contained nochannels.get, nochannels.has, no refusal — so it wrote onesys_notification_deliveryrow per recipient, and
NotificationDispatcherdead-lettered every one of them on attemptone (
processRowandprocessDigestGroupboth ackdead: truethe momentgetChannel()answers nothing).That is #17732's reported symptom: a row that exists only to die. Ruling
5644350987structurally 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
enqueueDeliveriesnow checks registration before it enqueues. An unregistered channelgets no row and the caller gets the same failed
DeliveryOutcomethe inline pathalready produced, so "nothing was sent and here is why" reads identically whichever path
served the emit.
EmitResult.failedcounts them;enqueuedanddelivereddo not, so nosummary 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_channelsCHANNEL_UNAVAILABLE_REASONSis documented on the seam as "Why a channel is notavailable 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 #18041 settled, from the other side. Recording it would need a new
literal in that closed set, which
channel-availability.test.tsholds byte-equal to thecopy inlined on
sys-notification.object.ts— i.e. an edit inpackages/platform-objects,which is
domain:engineand not this card's. Both reasons point the same way, and thesuppression 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-268as AGENTS.md's "Startup registry reads" three-partshape, on the grounds that "an email service that registers later never gets its
channel". Measured on this tree:
ObjectKernel.use()throwsCannot register plugins after bootstrap has startedwhenever
state !== 'idle'(packages/core/src/kernel.ts:240-243), so no plugin joinsthe composition after boot begins.
init(), not later:plugin-email/src/email-plugin.ts:430andservice-sms/src/sms-plugin.ts:214.kernel-base.ts:291states the contract directly:kernel:readyis "the only correctmoment 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 longerfilling 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
emailchannel, 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 readsopts.getEmail()live atemail-channel.ts:238— answertransport_not_configured. That would convert this wholeclass 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:252returns{ ok: true }when no email service is present("capability not installed — no-op"), and
sms-channel.ts:151does the same. The SMSchannel has no
isAvailableat all.row: wrong, but loud.
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 isa retry-semantics change (
classifyError, permanent vs retryable) with its own pins, andthe 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 twotest 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:
The probe, and the control that proves the probe can see a real export — run against
the freshly built
dist/index.d.ts:The word
refuseddoes appear 10× in the published.d.ts— 9 of them predate thisbranch 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.jscarries the new warn text, 1 hit), which is what makes the zeros aboveinformative rather than a probe that simply cannot see anything.
⇒
Clause-②: no. No member was added to a published interface, no value was added to apublished enum, no key was added to a published payload. A
patchchangeset is thereforethe correct grade, not the
minorayeswould require.Recovery round — the
Test Core (6/6)red, its root cause, and the fixThe previous push left
Test Core (6/6)failing. The shard log showscheck-test-completeness: @objectstack/plugin-auth was scheduled but never reachedand the same for
@objectstack/downstream-contract— 1 of 3 scheduled packagesreported, 2 never reached.
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.
That file is on
origin/main(landed by #7875 for card #7747) and this branchnever touched it. Its first case boots WITHOUT
pushregistered and asserted,as its scenario setup, that the durable fan-out wrote one delivery row and the
dispatcher dead-lettered it:
Those three lines describe exactly the defect #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-automationresolves@objectstack/service-messagingthrough
dist/(its vitest aliases only@objectstack/platform-objects), sonothing in the producer's own lane could surface it.
The fix — re-pinned, ⛔ not relaxed.
notify-delivery-outcome.integration.test.tsnow asserts the new producer contract:
dispatcher.tick()status: 'dead'acted: 0, unmeasured: 1selected: 1, acted: 0, unmeasured: 0acted <= non-dead rowsThe
unmeasured1 -> 0 move is the point, not a relaxation.unmeasuredEffectmeans "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.tsstates in its own words: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:
refuseside of fork 1 is right, and is kept. The registration checkmirrors the inline path's existing one, the failed
DeliveryOutcomeis thesame shape, and
notify-node.ts's own stated design is what makes theresulting
acted: 0, unmeasured: 0the correct answer rather than a loss ofsignal. ⛔ Nothing about the
suppressed_channelsreasoning was reversed.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 theaffected ones were run — see Evidence.
scripts/engine-double-contract.pinned.jsonis NOT in this diff (0 hitsover the whole branch range), so this card does not join the live serial relay.
check:engine-double-contractexits 0.Evidence — this round, measured on
a3950e4faservice-automationconsumes
service-messagingthroughdist/, so a run on an unbuilt treemeasures nothing. Closure build:
pnpm --filter '@objectstack/service-automation^...' build::
VERDICT command-exit 0.Reproduction, then the fix
Affected packages, full suites —
VERDICT command-exit 0@objectstack/service-automation@objectstack/service-messagingThe 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:@objectstack/plugin-auth(also a direct consumer)@objectstack/downstream-contractTypecheck —
VERDICT command-exit 0for both affected packages.check:test-typecheckreports service-automation's test layer compiles undertsconfig.test.json, 0 files / 0 errors, andtsc -p tsconfig.test.json --listFilesputs the edited file in the program (1 hit) — so the green isattributable, 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 mutationwas proven to have reached the artifact before the colour was read:
The script installed a
trapon 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 --commandsDerived on this tree with
--repo objectstack-ai/objectstackasserted:⛔ The 49 are UNRUN, not passed. They are the
Lint & Repo Gatesfarm, whichthe 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:
plus the two affected packages'
testandtypechecktasks counted above. Acontrol-character self-scan over all five files in the branch range
(
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') matched nothing.dispatch-gates.mjsreports this tree is 2commits behind
origin/main(57343f761), withscripts/pm/check-skill-line-ratchet.mjschanged in that range. This diff touches no
skills/**, so no family is added byit, but the derivation above is a reading about
a3950e4faand not about themerge the queue will build.
Acceptance notes — observed, not filed, not fixed here
fanOutlogs its "not registered" warn once per(recipient × channel)pair rather thanonce 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:151returning{ ok: true }for an absenttransport is already recorded on [finding] The outbox fan-out writes a delivery row for a channel that is NOT registered — #17732's reported symptom, which ruling A does not reach #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.tsimplements noisAvailable, 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.
service-automation, not inthis card's landing package. It is in-lane (
domain:services) and was owed bythis diff's own behaviour change, so it is fixed here rather than filed.
sms-channel.tsstill implements noisAvailable, andemail-channel.ts:252/sms-channel.ts:151still answer{ ok: true }for an absent transport. Bothare already recorded on [finding] The outbox fan-out writes a delivery row for a channel that is NOT registered — #17732's reported symptom, which ruling A does not reach #18050's body; noted, not filed, so the card stays the
single home for them.
origin/main. No merge was taken in thisround: 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