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
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