Commit e07eecf
fix(messaging): mount the email and SMS channels per lookup, not once at kernel:ready (#18272)
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 `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** — #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" (#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
#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 #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`. 1 parent 842fa04 commit e07eecf
5 files changed
Lines changed: 521 additions & 28 deletions
File tree
- .changeset
- packages/services/service-messaging/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
Lines changed: 263 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
Lines changed: 44 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
121 | 121 | | |
122 | 122 | | |
123 | 123 | | |
124 | | - | |
| 124 | + | |
| 125 | + | |
125 | 126 | | |
126 | 127 | | |
127 | 128 | | |
| |||
130 | 131 | | |
131 | 132 | | |
132 | 133 | | |
133 | | - | |
| 134 | + | |
134 | 135 | | |
135 | 136 | | |
136 | 137 | | |
| |||
152 | 153 | | |
153 | 154 | | |
154 | 155 | | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
155 | 197 | | |
156 | 198 | | |
157 | 199 | | |
| |||
0 commit comments