Skip to content

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

Merged
os-project-manager merged 5 commits into
mainfrom
claude/issue-17732-channel-availability-fanout
Sep 13, 2026
Merged

os-project-manager merged 5 commits into
mainfrom
claude/issue-17732-channel-availability-fanout

Conversation

@claude

@claude claude Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #17732

Implements the ruling 5644350987 (director seat, decision batch #122 item 5, 2026-09-12), routed to domain:services through triage's cross-domain exception path (5650914775).

Clause-②: yes

What changed

Fan-out asks a channel whether the tenant can send on it before it writes anything.

  1. MessagingChannel gains one OPTIONAL member — isAvailable(ctx, { organizationId }) answering { available: true } or { available: false, reason } from the closed vocabulary CHANNEL_UNAVAILABLE_REASONS (today: transport_not_configured).
  2. emit() consults it once per channel, before the event write — a channel that answers unavailable gets no sys_notification_delivery row and no send() call, on the outbox (P1) and the inline (P0) path.
  3. sys_notification gains one key, suppressed_channels — [{ channel, reason }], NULL when nothing was suppressed — written in the same insert that creates the event row, so the feature costs zero additional writes.
  4. The email channel implements it, answering from the transport it was handed. The inbox channel implements nothing — which is literally "always available" per the ruling, and doubles as the production instance of the optional-member default.
  5. EmitResult gains suppressed so 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

"the email channel answers from the tenant's transport configuration — the seat measures whether that answer is cheap at fan-out time and caches per tenant per tick if it is not"

Measured, not assumed. Two readings, both on this branch:

ns/op
email.isAvailable() — service present 4.8
email.isAvailable() — service absent 31.1
(scale) empty arrow call 10.2
(scale) await a resolved no-op findOne — the in-process floor of any I/O-shaped answer 121.3
(scale) a real single-row SELECT over loopback O(100 000)

And the call count, driven through the real emit() with 50 recipients × 2 channels:

availability probes performed : 1
delivery rows written         : 50  (0 email)
delivery rows AVOIDED         : 50

⇒ 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 mail settings namespace at scope: 'global' (packages/services/service-settings/src/manifests/mail.manifest.ts), materialised into a single in-memory IEmailTransport that EmailServicePlugin hot-swaps from the settings change bus. A per-tenant-per-tick memo would keep answering "unavailable" straight through the settings save that fixed it.

⚠️ A reading the ruling's wording did not anticipate, reported rather than papered over: there is no per-tenant transport configuration in this tree to read — 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.

control what it catches
unavailable channel gets no row, an available channel in the same fan-out does, and the reason is on sys_notification — asserted on one pass "no row was written" alone also passes a fan-out that wrote nothing at all; "the available channel got its row" alone also passes the old code
a channel with no isAvailable still gets its delivery row, and suppressed_channels is NULL an implementation that inverted the optional-member default would pass every suppression test here and silently mute every channel that has not been updated — far worse than the bug being fixed
the same channel suppressed once it does answer unavailable pairs with the row above on one variable, so "it got its row" cannot be read as "suppression never works in this harness"
probe count is 1 for a 4-recipient emit, and carries the tenant a probe that ran per delivery would multiply its cost by the audience — the thing the ruling asked to be measured
a throwing probe still delivers, and warns fail-open: a broken availability check must degrade into today's behaviour, never into a silent notification outage
the object's inlined reason enum equals CHANNEL_UNAVAILABLE_REASONS packages/platform-objects is a lower layer and cannot import the vocabulary, so the two copies are held equal by an assertion — a comment would not
an unregistered channel keeps its existing path pins the scope boundary as deliberate rather than accidental

⚠️ Two reds this PR caused, and what they were

Both 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 in notify-zero-delivery-visibility.integration.test.ts

The error text names the cause exactly:

Node 'notify' failed: notify failed: Unknown field 'suppressed_channels' on object 'sys_notification'

That harness declares sys_notification as a fixture whose own comment says it is "exactly the columns MessagingService.writeEvent inserts — 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_channels on every insert (as null when nothing was suppressed). An insert names its columns, so that made every emit() in the world depend on every sys_notification schema already carrying the new column — losing the whole notification to record that nothing was suppressed. writeEvent now 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 in channel-availability.test.ts: the key is absent (not null) 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:

tree pnpm --filter @objectstack/service-automation test
merge-base 1e20f816e, clean worktree, none of my commits 134 files / 1581 tests passed, 0 failed
my branch e57ee063c (column named on every insert) 1 file failed / 4 tests failed (CI run 34763053926)
my branch 684df79a9 (column named only when non-empty) 134 files / 1581 tests passed, 0 failed

And a second, independent control on the cause itself: suppressed_channels occurs 0 times in the merge-base tree and 13 times on this branch, with dedup_key as the positive control for the same git grep against that same tree (44 files). A failure whose cause is a string that does not exist on origin/main cannot be pre-existing.

2. Type Check · workspace — Object.hasOwn is outside this package's lib

Two sites, both in the test file added by the previous push. Reproduced first (pnpm --filter @objectstack/service-messaging typecheck → exit 2, error TS2550 at channel-availability.test.ts:188 and :356), then fixed to Object.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/target to make Object.hasOwn resolve 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.call keeps it; key in obj would also answer for inherited keys and !== undefined would conflate the two, so neither was usable. The receiver is the plain object literal writeEvent builds — ordinary prototype, no own hasOwnProperty key — and the .call form is correct regardless of either hazard.

Gates — final, on 684df79a9

pnpm lint                                        exit 0   (eslint . --no-inline-config, repo-wide, not narrowed)
pnpm --filter service-messaging --filter platform-objects typecheck   exit 0
pnpm --filter service-messaging --filter platform-objects --filter service-automation test   exit 0
  platform-objects     Test Files  40 passed (40)    Tests   575 passed (575)
  service-messaging    Test Files  42 passed (42)    Tests   455 passed (455)
  service-automation   Test Files 134 passed (134)   Tests  1581 passed (1581)

95 gate commands run to a real verdict, 0 non-zero:

  • 62 derived mechanically from the change set (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack; re-derived after the final commit and byte-identical to the first derivation).
  • 33 named by .github/workflows/lint.yml and not in that derived set — including check:doc-anchors, check:adr-anchors, check:verify-stand-in, check:entry-guard, check:parse-guard, check:bash32-floor, check:sdui-lockstep, check-sdui-manifest, the check:pm-* family, check:durability-log-level, check:startup-registry-verdict and the two scripts/pm/*.sh --self-tests.

⚠️ Not the whole Lint & Repo Gates job. 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 ⛔ no exit 3 was read as a pass:

gate first answer after building the closure it named
check:i18n exit 3 → exit 1, a real finding (platform-objects bundles drifted) → regenerated → exit 0
check:i18n-coverage exit 3 → exit 0 (13 configs, 621 baselined, none new)
check:dual-build-cjs-loads exit 3 → exit 0 (104 require entry points across 67 packages)
check:type-check-debt exit 3 → exit 0 (5 ledger entries re-measured, none above its recorded number)

⚠️ check:where-matcher is green (407 matchers, 0 silently-wrong, 0 unjudged, no files added to the baseline). This PR adds no in-memory matches(row, where) double — its engine double answers insert and find only.

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:

pnpm --filter @objectstack/service-messaging --filter @objectstack/platform-objects typecheck   exit 0
pnpm --filter @objectstack/service-messaging --filter @objectstack/platform-objects test        exit 0
  platform-objects            Test Files  40 passed (40)   Tests  575 passed (575)
  service-messaging           Test Files  42 passed (42)   Tests  454 passed (454)

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:

gate first answer after building the closure it named
check:i18n exit 3 (no dist/ for the 10-package extract closure) built it → exit 1, a real finding: platform-objects bundles drifted → regenerated → exit 0
check:i18n-coverage exit 3 see below
check:dual-build-cjs-loads exit 3 (55 packages with no dist/) see below
check:type-check-debt exit 3 see below

⛔ No exit 3 was read as a pass anywhere.

Out of the declared file surface — reported, not ridden in

⚠️ The claim's declared file surface was incomplete, and the gate proved it. Adding a labelled field to sys_notification drifts packages/platform-objects/src/apps/translations/*.generated.ts, which pnpm check:i18n fails 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.

  • ⚠️ The card's premise is factually wrong in one place, and the wrong half is the reported one. [Decision] service-messaging: should fan-out consult per-tenant channel availability (a new MessagingChannel member) 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 in channels that is not registered gets a delivery row per recipient, and NotificationDispatcher dead-letters it with dead: true on 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.
  • The email MessagingChannel is registered only if (getEmail()) at kernel: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 direct send().
  • The card and the ruling both say "inbox, email today"; the tree also ships 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

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/service-messaging, touching 20 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-messaging/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/automation/hook-bodies.mdx (via sys_notification (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/automation/hooks.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/data-modeling/seed-data.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/deployment/seed-tenancy-repair.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/kernel/events.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/kernel/runtime-services/audit-service.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/permissions/authentication.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/permissions/system-context.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/plugins/packages.mdx (via MessagingChannel (symbol, a top-level interface))
  • content/docs/protocol/kernel/config-resolution.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/releases/v16.mdx (via organizationId (symbol, a field of interface ChannelAvailabilityQuery))
  • content/docs/releases/v17/17-0.mdx (via sys_notification (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-2.mdx (via MessagingService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-messaging/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (7 routes)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c185d087b61df5afe54bbfffc0783e6b2378bf14 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9140b6676794870d2e9b7135d3ab59d540562beb — the merge of head 684df79a9e28e1685f5d085263420d044b97880c into base c185d087b61df5afe54bbfffc0783e6b2378bf14, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c185d087b61df5afe54bbfffc0783e6b2378bf14 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Head reviewed: 684df79a9e28e1685f5d085263420d044b97880c
Implemented-by: claude/issue-17732-channel-availability-fanout (mode:subagent — the branch, not a session)
Reviewed-by: session_01URLHobLUJB9K1ABV6ofdjj (domain:services execution seat)

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

path registration check
inline P0 fanOut ✅ messaging-service.ts:1212 this.channels.get(channelId), refusing at :1218
outbox P1 enqueueDeliveries (:1089) ⛔ none — no channels.get, no channels.has, no refusal anywhere in its loops

⇒ on the outbox path an unregistered channel gets one delivery row per recipient, and the dispatcher dead-letters each on attempt one (dispatcher.ts:283, :328, both dead: true). That is the symptom #17732 opens with, and ruling A structurally cannot reach it — its member is a property of a channel implementation, and an unregistered channel has none to ask.

✅ The delivery was right to leave it on its existing path and pin the boundary rather than widen. ⚠️ This seat overrode the report's "noted, not filed": a defect that is the parent card's own reported symptom, whose successor is "whoever next touches fan-out", is a defect that gets lost. Filed as #18050, with the second half the report found — messaging-service-plugin.ts:264 makes registration a one-shot kernel:ready verdict while the comment directly above it claims late registration is fine.

① Clause-② — yes, and BROADER than the ruling anticipated

Re-derived from the built entry with controls: dist/index.d.ts declares isAvailable?() on the exported MessagingChannel, plus CHANNEL_UNAVAILABLE_REASONS / ChannelAvailability / ChannelAvailabilityQuery / ChannelUnavailableReason / ChannelSuppression. Positive control type MessagingChannel, = 1; negative controls EMAIL_SHAPE / actionUrlFor / READ_RECEIPT_STATES (all module-private) = 0 each, so the zeros discriminate. Limb 2: suppressed_channels is in platform-objects/dist/index.js, inside that package's published files[].

⚠️ Worth naming because the ruling did not: EmitResult.suppressed is a third widening, and it is REQUIRED, not optional. A required new key on a published result type is a stronger move than the two limbs the ruling listed. It earns its place — a two-channel emit reporting one delivery row is otherwise indistinguishable from a fan-out bug — but it belongs in the record.

check-clause2-carriers --pair 18041 → exit 0, captured before any pipe. ⭐ And this is the first pair this session the gate passes without the ATTRIBUTION NOT VERIFIED advisory — because this claim carries the Session: line that correction 166 added. The fix is confirmed by the gate, not by my say-so.

② The ruled items, and the one that is quietly the best

  • Optional member ✓ — and ⭐ the inbox channel deliberately does NOT implement it. "Always available" is literally true of the inbox, so the production instance of the optional default is a real channel rather than a test fixture. That is the strongest possible evidence that a non-implementing channel still gets its row.
  • emit() consults once per channel before the event write ✓ — 1 probe for 50 recipients × 2 channels, 50 rows avoided ⇒ O(channels), never O(deliveries).
  • Closed reason vocabulary inlined, ⛔ not in spec ✓ — following sys-notification.object.ts:96's own Field.select([...]) precedent, re-verified as still present. packages/spec files in the diff: 0. content/docs/releases/**: 0.
  • The suppression key rides the same insert that creates the event row ⇒ the feature costs zero extra writes.

③ The cost number the ruling demanded — and an honest reading of it

Ruling item 3 required the seat to measure whether the answer is cheap at fan-out time and cache only if not. Measured: isAvailable() = 4.8 ns/op present, 31.1 ns/op absent, against 10.2 ns/op for an empty arrow call and 121.3 ns/op for merely awaiting an already-resolved no-op findOne.

⭐ The report refuses to over-read its own numbers: "the three nanosecond figures are within 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, on the ruling's own condition.

⭐ And it supplies a second reason the ruling did not have, which is the better one: the mail settings manifest is scope:'global' and the transport is one in-memory instance hot-swapped by the settings change bus — so a per-tenant-per-tick memo would answer "unavailable" straight through the settings save that just fixed it. A cache here would not be a cost trade; it would be a correctness bug.

④ The CI incident — fixed in the PRODUCER, with the control that settles attribution

The first push turned Test Core (4/6) red: 4 tests in service-automation's notify-zero-delivery-visibility.integration.test.ts, "Unknown field suppressed_channels on object sys_notification". ⛔ No assertion was retuned and that fixture is untouched. The first draft named the new column on every insert; writeEvent now adds it only when something was actually suppressed, so the common path's column set is exactly what it was.

Three readings, plus an independent control on the cause itself:

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

⚠️ Binds to the head it names. Landing state, measured: 11 workflow runs, all completed and clean; 34 check runs, 31 success + 3 skipped, 0 non-green. ⛔ It touches neither 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

@os-project-manager
os-project-manager marked this pull request as ready for review September 13, 2026 15:48
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit a2c2852 Sep 13, 2026
46 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-17732-channel-availability-fanout branch September 13, 2026 16:14
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…hannel instead of writing a delivery row that can only dead-letter (objectstack-ai#18081)

Part of objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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 objectstack-ai#7875 for card objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…not report success (objectstack-ai#18562)

Fixes objectstack-ai#18424

Clause-②: no — this PR adds no new key to a published payload and no new
error code. It NARROWS the accept set: `send()` withdraws an `{ ok: true
}` it was never entitled to give. The refusal token
`transport_not_configured` is a **pre-existing** member of
`CHANNEL_UNAVAILABLE_REASONS`
(`packages/services/service-messaging/src/channel.ts:114` on
`origin/main`, negative control 0), and the delivered diff adds **zero**
`export` statements (its only `+` line containing the word is a
comment). Re-derived from the DELIVERED diff by the dispatching
`domain:services` seat (claim `5706661708`), ⛔ not carried forward from
the claim-time prediction.

`email-channel.send()` and `sms-channel.send()` returned `{ ok: true }`
when their transport was not installed — "capability not installed —
no-op". The `sys_notification_delivery` row reached `status: 'success'`,
nothing went red, no row dead-lettered, and a deployment with an
unconfigured email or SMS transport reported every notification as
delivered. The same object's `isAvailable()` already answered the
identical condition correctly: `{ available: false, reason:
'transport_not_configured' }`.

## The measurement the card assigned, driven both ways

The card did not prescribe the fix; it asked whether *transport absent*
is the same case as *channel unmounted*, and required that be driven
rather than read off the source. It was driven against `origin/main` at
`79a046f8`, with the real channels through a real `MessagingService`, a
`MemoryNotificationOutbox` and a real `NotificationDispatcher` tick.

| leg | wiring | transport | reading |
| :-- | :-- | :-- | :-- |
| A | plugin (`registerChannelProvider` + `lazyChannelMount`) | absent
throughout | `send()` never reached · `channel 'email' not registered` ·
`failed: 2` · no rows · no suppression |
| B | plugin | present at emit, gone before dispatch | rows enqueued,
then `status: 'dead'`, `attempts: 1` |
| C | direct `registerChannel` (public export) | absent throughout |
email suppressed at fan-out · **sms row `status: 'success'` with nothing
sent** |
| D | direct `registerChannel` (public export) | present at emit, gone
before dispatch | **both rows `status: 'success'` with nothing sent** |
| E | plugin (control) | present throughout | both deliver |

**Answer: not the same case at the fan-out seam; the same case at the
`send()` seam.**

- At the **fan-out** seam the tree already discriminates them, and the
discrimination is load-bearing. Transport absent is a per-tenant
configuration fact — SUPPRESSED, recorded on
`sys_notification.suppressed_channels`, `failed: 0`. Unmounted is a
deployment composition fact — REFUSED, one failed `DeliveryOutcome`, no
suppression record. Leg A and leg C show both answers side by side.
- At the **`send()`** seam there is no suppression left to record: the
delivery row already exists and `SendResult` carries no suppression arm.
The only two answers available are the lie `{ ok: true }` and a refusal.
- And in the composition that ships, the two conditions are **one
predicate**. `lazyChannelMount`'s `resolveTransport` *is* `getEmail` /
`getSms` — the very resolver `isAvailable()` answers from and `send()`'s
no-op branch tested. Transport absent therefore already means unmounted
there (leg A), and already means REFUSED.

So `send()` owes the refusal. That lands on the refusal side of objectstack-ai#18041's
settlement rather than moving it, which is why the boundary pin is
untouched and green.

## What this changes

- **`send()` refuses with the reason `isAvailable()` already returns**:
`{ ok: false, error: "transport_not_configured: no 'email' service is
registered; nothing was sent to 'RECIPIENT'" }`. The token is the
declared `CHANNEL_UNAVAILABLE_REASONS` member, held inside that closed
set by a type annotation (`const TRANSPORT_NOT_CONFIGURED:
ChannelUnavailableReason`) — a token nobody declared would not compile.
**No new error code**: no `ERROR_CODE_LEDGER` and no `StandardErrorCode`
entry, so `Clause-②: no` stands.
- **`classifyError()` grades that refusal `permanent`**, so the row
dead-letters on attempt one. Driven, not assumed: leg B is the shipping
composition's own answer to this identical condition — `dead`,
`attempts: 1`. Grading it `retryable` would make the two compositions
end one condition differently and burn the whole ladder against a
transport no attempt can install.
- **Not a suppression**, for the reason above.

## Verification

- `pnpm --filter @objectstack/service-messaging test` — **44 files, 473
tests, all pass**; `typecheck` green in the same invocation.
- **R2's boundary pin is untouched and green.**
`channel-availability.test.ts` is not in the diff; re-run verbosely with
its two siblings: `channel-availability.test.ts` 10/10,
`unregistered-channel.test.ts` 5/5, `lazy-channel-mount.test.ts` 5/5.
- **Ablation** (the forbidden `{ ok: true }` put back on both channels,
proven on disk by blob hash and by before/after occurrence counts, then
restored from `HEAD` and proven byte-identical — `git diff HEAD` empty,
both blob hashes equal to the `HEAD` blobs):

```
MUTATION CONFIRMED ON DISK: email 7fc96c9 -> 51ea2f7c ; sms 686840a -> 384338f2

 × refuses with the SAME declared reason isAvailable() returns
   AssertionError: expected true to be false
 × END TO END: one emit, one outbox, one dispatcher tick - the row lands `dead`, not `success`
   AssertionError: expected 'success' to be 'dead'
 × refuses with the declared transport_not_configured reason
   AssertionError: expected true to be false
 × END TO END: one emit, one outbox, one dispatcher tick - the row lands `dead`, not `success`
   AssertionError: expected 'success' to be 'dead'
 Test Files  2 failed (2)
      Tests  4 failed | 56 passed (60)

RESTORED: email=7fc96c96 sms=686840a5 ; git diff HEAD = '' (want empty)
```

The controls stayed green under the same mutation, so the pins
discriminate rather than just tracking the run.
- **Downstream**, against the built `dist` carrying the change:
`@objectstack/service-automation` — 136 files, 1603 tests, plus the 6
integration files that needed the closure built, 31 tests. All green. It
is the `notify`-node consumer of this path.
- **Gates**: derived with `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` against the actual 5-path change
set at `a067e667`, each exit code captured to a file before being read,
then reconciled with `--ran`: **64 derived, 61 run, 3 NOT-MEASURED, 0
UNRUN**.
- The 3 are `check:dual-build-cjs-loads`, `check:i18n` and
`check:type-check-debt`, each exit **3** = `PREREQUISITE NOT MET` (they
read a whole-repo build). Declared to CI, which builds.
- `check:cross-package-test-inputs` exited 1 locally, naming
`packages/cli/test/init-created-files-summary.e2e.test.ts` walking
`packages/spec/dist/` — no path of this diff. Two-leg control, my diff
present in both legs: with `packages/spec/dist` on disk it reds; with
that gitignored build artifact moved aside it exits **0** (216 files
verified restored). `lint.yml` states the `Lint & Repo Gates` job runs
`pnpm install` and never builds, so CI never sees that directory. Noted
below as a finding rather than carried here.
- **Lint, narrowed and the narrowing proven**: `pnpm exec eslint
--no-inline-config --format json` over the 4 changed TypeScript files —
4 files linted (count read from the JSON, not asserted), 0 errors, 0
warnings, exit 0. The narrowing is safe because this repo's single
`eslint.config.mjs` **never enables type-aware linting for any file**
(no `parserOptions.project`, no typed `@typescript-eslint` rules —
stated and measured at `eslint.config.mjs:328`), so nothing in this diff
can move the verdict on a file it does not touch. Run at `a067e667`, the
final commit.

## Acceptance notes

Observations outside this card's scope, recorded rather than fixed here.

- **`sms-channel` declares no `isAvailable()`.** `email-channel` is the
only implementation of that member in the repository, so fan-out cannot
suppress `sms` where it can suppress `email` — leg C is that asymmetry.
It is immaterial under `MessagingServicePlugin`, where the mount gate
refuses first (leg A), and it does not affect this fix: the refusal is
graded `permanent`, so a transport-absent sms delivery ends in one dead
row rather than a retry ladder. Worth its own card as the unfinished
half of objectstack-ai#17732.
- **`check:cross-package-test-inputs` reds as a function of local build
state**, per the two-leg control above. Anyone who builds
`packages/spec` before running the gate chases a finding CI cannot see.
- Noted, not filed: the `{ ok: true }` branch is unreachable under the
only in-tree composition (`MessagingServicePlugin`), and
`createEmailChannel` / `createSmsChannel` have no other construction
site in this repository — so the defect's live surface is the public
export rather than anything this tree runs. Recorded because it is the
reason the fix targets a published contract and not a live code path.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01WmBwEiWPff9JZPd5BSGNeH


---
_Generated by [Claude Code](https://claude.ai/code)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants