fix(messaging): mount the email and SMS channels per lookup, not once at kernel:ready - #18272
Conversation
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
… at kernel:ready The plugin registered both channels behind a guard that ran exactly once, at kernel:ready, so a transport service registering later in the same boot never got its channel and every notify naming it was refused for the life of the process. MessagingService.registerChannelProvider resolves a mount on every lookup; an absent transport keeps today's refusal, writes no delivery row and records no suppression. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also 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 aeaa5109cc8c9d19286f43f847e620fd0354af6a && git checkout aeaa5109cc8c9d19286f43f847e620fd0354af6a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e9091487f5adeb74b580b927921ee83e32550a84 6166e303cec1416f0f9ab3e772b0fabbafd8a85f && git checkout -B drift-repro e9091487f5adeb74b580b927921ee83e32550a84 && git merge --no-ff 6166e303cec1416f0f9ab3e772b0fabbafd8a85f
node scripts/docs-audit/affected-docs.mjs --json e9091487f5adeb74b580b927921ee83e32550a84
|
The diff adds MessagingService.registerChannelProvider to an already-published class. A purely additive widening of a published surface takes at least minor (maintainer ruling 2026-09-04, decision batch #35), and the PR declares clause two yes, so patch was a self-contradiction inside one PR. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
The #18050 plugin tests wrote `const messaging: any = ctx.getService(...)`, which is the erasure shape slot-lookup bans; the file is grandfathered for its existing sites only, so the ratchet grew 2 to 4. Typed at the source with the spelling this file already uses; the two grandfathered sites are left to the sweep batch that owns them and the baseline is untouched. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Default-tier, in-seat, per ① Derived judgments
② Semver level
③ Boundary flags
⭐ One thing recorded in the delivery's favour, because it is the opposite of the failure mode this review exists to catch: the Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Provenance for the carrier strip — The dual
Generated by Claude Code |
Part of #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 #18081 and
is on
maintoday, so it is not what this PR changes. See "Premise re-verified" below: that halfof the card's stated premise has DRIFTED and no longer reproduces.
Premise re-verified on today's
origin/main, driven rather than readRe-derived by symbol (every line number on the card is a stale anchor), at merge base
e592b4b6b:fanOutchecks registrationgetChannel(channelId), refusing one line downenqueueDeliverieshas NO checkif (getEmail())atkernel:ready)Both directions were driven end to end through a real
MemoryNotificationOutboxand a realNotificationDispatcher.tick(), not concluded from the shape of the loops. A temporary probeemitted on
['inbox','email']withemailnever registered:So the card's reported symptom is real and is already cured on
main; the probe file was deletedafter both legs ran (restore proven by blob identity, see Evidence).
What was wrong with the half this PR takes
messaging-service-plugin.tsmounted the email and SMS channels inside akernel:readyhook,behind
if (getEmail())/if (getSms()). The comment above that guard reasoned "the dispatcherlooks channels up dynamically, so registering after it is fine" — true of the dispatcher, and
contradicted by the guard beneath it: the
ifran exactly once and nothing revisited it. Atransport that registered later in the same boot — a plugin ordered after this one registering
from its own
kernel:readyhandler,kernel:bootstrapped,kernel:listening, or a runtime mount— never got its channel, and every
notifynaming it was refused as "not registered" for the lifeof 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-verdictis green on it both beforeand 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 EVERYlookup;
getChannelasks the provider, andgetRegisteredChannelslists only what resolves rightnow. 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 claimis 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, nosys_notification_deliveryrow is written, andnothing is recorded in
sys_notification.suppressed_channels— an absent mount is a COMPOSITIONfact, identical for every tenant in the process, and that column answers "why can this TENANT not
send" (#18041's settlement). Whether a MOUNTED channel can send is the separate question
isAvailableanswers, and this PR does not touch it.channel-availability.test.tsandunregistered-channel.test.tsare unchanged and green: theboundary 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
isAvailableanswertransport_not_configured— was rejected for exactly that reason: it would move a deployment-widecomposition fact into the per-tenant suppression column, re-opening what #18041 settled, and (SMS
implements no
isAvailable) would turn an absent SMS transport into a delivery row recorded asSUCCESS 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, andtsc -p tsconfig.json --listFilesputs both edited test files in the program (1 hit each), so thegreen 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
distleg is owed: the suite isin-package and vitest resolves these modules from
src.presentOnce)presentOnce0/2Tests 2 failed, 7 passed— "expected [ 'inbox' ] to include 'sms'"4e177e236,git diff HEADempty, 9 passed afterresolvedOnce0/3Tests 1 failed, 4 passed— "expected [ 'inbox', 'email' ] to not include 'email'"428fc9e93, 5 passed afterdeadon attempt 1428fc9e93, anchor back to 1Gates — 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 thechangeset 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. Pluspnpm check:startup-registry-verdict(exit 0), run because it owns this defect shape although thederivation does not name it.
⊘ NOT MEASURED:
pnpm check:type-check-debtexited 3 — PREREQUISITE NOT MET (it wants thewhole-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
exportand ⛔ not from a bundle grep. Probed through the published entry(
exports["."]todist/index.js/dist/index.d.ts) after building the package:exportlines across the whole diffsrc/index.ts(barrel re-export list)MessagingService.prototype.registerChannelProviderdist/index.d.tsregisterChannel)lazyChannelMount(plugin-local helper)undefined, and absent from the d.tsChannelProvidertypeundefined— the probe can answer no⇒ the published surface of an already-published entry grows by one callable member, so the⚠️ The card's claim comment declares
direction is a WIDENING and the honest declaration is
yes.Clause-②: noas a prediction; that prediction is falsified by this diff, and correcting thecard's declaration and carrying the label on the card side are the seat's acts, not this branch's.
needs:contract-reviewis on this PR.Not in scope, recorded
email-channel.tsandsms-channel.tsstill answer{ ok: true }when their transport is absent,so a direct
send()from the dispatcher can record a delivery as success with nothing sent. Thecard'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
661c9b611and both were mine. Neither was answered by looseninganything.
1.
Check Changeset— the changeset grade was the side that was wrong. The PR declaresclause ②
yes, and.changeset/lazy-messaging-channel-mounts.mdgradedpatch. Under themaintainer ruling of 2026-09-04 (decision batch #35, on #15294) a purely additive widening of a
published package's public surface takes at least
minor, and my own Clause-② derivation above isthe 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 isnew.
node scripts/check-changeset-no-major.mjs --base origin/maindriven offline against thisPR's own event payload: "✓ LEVEL AXIS: this PR declares clause-②
yes, and no package whosepackages/**/src/**it moves is gradedpatch", exit 0. The lockstep cost is real and namedrather than dodged: every publishable package is in the
fixedgroup, so this minor moves thegroup'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 isright and my tests were wrong: both plugin tests this PR added wrote
const messaging: any = ctx.getService('messaging'), which is the erasure shapeslot-lookup/no-any-assignmentbans, andmessaging-service-plugin.test.tsis grandfathered forits 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 grandfatheredsites were left alone and
scripts/slot-lookup-baseline.jsonis untouched: sweeping them is thebatch 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).
reading was contract-legal, but a diff that adds test code doing service lookups owes
check:slot-lookuplocally, 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), andtypecheck— one chained run, VERDICT command-exit 0. Both ablations re-run at this head becausethe 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 toits HEAD blob with
git diff HEADempty.Authored by Claude Code, session
session_01URLHobLUJB9K1ABV6ofdjj.Generated by Claude Code