You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(service-messaging): the durable fan-out refuses an unregistered channel instead of writing a delivery row that can only dead-letter (#18081)
Part of #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 #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 #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 (#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 #7875 for card #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 #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 #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 #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 #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>
0 commit comments