Repository navigation
[finding] The outbox fan-out writes a delivery row for a channel that is NOT registered — #17732's reported symptom, which ruling A does not reach #18050
Description
Activity
Blocked, with the reason.
Both decision forks stand on machinery that arrives with PR #18041 (parent card #17732), which is in the merge queue and not yet on
main:- Fork 1 offers to "record it as suppressed the way 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 records
transport_not_configured" — that shape is introduced by 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. - Fence 3 requires the fix not re-open what 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 settled about the suppression key on
sys_notification. A dev standing on amainwithout 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 cannot see what was settled, so the fence is unreadable from there.
The card is therefore dispatchable only onto a
maincarrying #18041. It was filed at15:45Zwhile that PR was still open; recorded now so the board does not offer it.Exit is mechanical: #18041 lands, this card returns to
pm:queue.
Generated by Claude Code
- Fork 1 offers to "record it as suppressed the way 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 records
Unblocked — PR #18041 merged, and the block was re-derived rather than reflexively released (state-machine rule: 上游已关 ⇒ 先重新推导是否有新阻塞).
Measured on
origin/main6d647858b, the machinery both decision forks stand on is now present:email-channel.ts:238 isAvailable(_ctx, _query): ChannelAvailability email-channel.ts:245 { available: false, reason: 'transport_not_configured' } channel-availability.test.ts present (blob c09afd1b2) messaging-service.ts:145 readonly suppressed: readonly ChannelSuppression[]No new blocker derives: fork 1's suppression shape, fork 2's registration question and fence 3's
sys_notificationsettlement are all readable frommainnow.Blocked-by:/Unlock-action:lines removed from the body; back topm:queue, ungraded changes none.
Generated by Claude Code
Claim: #18050 — the outbox fan-out writes a delivery row for a channel that is NOT registered
Session: session_01URLHobLUJB9K1ABV6ofdjj
Branch: claude/issue-18050-fanout-unregistered-channel
Clause-②: yesWhy
yes— a prediction, to be re-derived from the DELIVERED diff, ⛔ not carried forward from this line. The card's fork 1 offers recording an unregistered channel "as suppressed the way #18041 recordstransport_not_configured". The suppression reason vocabulary is inlined on the object (sys-notification.object.ts:96,severity: Field.select([...])), so taking that fork WIDENS the accept set of an already-published payload. ⭐ If the delivered diff takes fork 1's other side (refuse outright, no row at all) and adds no published value, the re-derivation may land onno— that is the dev's to measure and this seat's to re-check.Container & model —
node scripts/pm/dispatch-gates.mjs --tier packages/services/service-messaging/src/{messaging-service,messaging-service-plugin,dispatcher,email-channel}.ts, driven fresh in a worktree atorigin/main(⛔ not the shared checkout, correction 169), answers:Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled. The tier stays the PM's per-card judgment call (floor sonnet · default opus · ceiling fable).
⇒ default judgment tier, chosen because this card carries two open DESIGN forks and a fence about not re-opening what #18041 settled. ⛔ Not the floor: nothing here is mechanical.
File surface (region level, a prediction to be re-checked against the delivered list):
packages/services/service-messaging/src/—messaging-service.ts(theenqueueDeliveriesoutbox path at ~:1089),messaging-service-plugin.ts(thekernel:readyone-shot registration at ~:261-268),dispatcher.tsif the dead-letter shape moves,email-channel.tsonly if fork 2 reaches it.- its co-located
*.test.tsfor the pins. - ⛔
packages/spec— not this lane's. ⛔content/docs/releases/**.⚠️ packages/platform-objects/**isdomain:engine: 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 reached it through a triage cross-domain exception (5650914775) and ⛔ that permission does NOT carry over.
Serial constraints cleared — driven, not recalled: every OPEN PR in the repo was listed and its file list fetched; 0 of them touch
packages/services/service-messaging/**.⚠️ scripts/engine-double-contract.pinned.jsonis a repo-wide ledger currently under a live 2-baton serial relay (#18022 landing, #18046 held): this card names no engine double, so it should not touch it — if the work adds one, STOP and report before pushing, because it would join that relay.
Generated by Claude Code
13 remaining items
os-dev-report
{ "issue": 18050, "status": "done", "branch": "claude/issue-18050-outbox-fanout-unregistered-channel", "pr": "https://github.com/objectstack-ai/objectstack/pull/18272", "premise_still_valid": false, "summary": "The card's premise HAS DRIFTED on half 1 and holds on half 2, so the PR takes half 2 and says 'Part of', not a closing keyword. Half 1 (the outbox fan-out writing a delivery row for an unregistered channel) was already cured on today's origin/main by PR #18081, which this card's own history records: enqueueDeliveries now refuses and counts, and unregistered-channel.test.ts pins it. Driven, not read: a temporary probe emitted on ['inbox','email'] with email never registered and ticked a real NotificationDispatcher -- leg A (main as-is) rows_written=0, after_one_tick=[], counts {enqueued:2,failed:2,suppressed:[]}; leg B (the same tree with the landed guard removed, i.e. the shape the card describes) rows_written=2, after_one_tick=[{status:'dead',attempts:1,error:\"channel 'email' not registered\"} x2], counts {enqueued:4,failed:0}. So the reported symptom is real and is already fixed; the probe file was deleted after both legs, restore proven by blob identity. Half 2 (registration read as a ONE-SHOT VERDICT) reproduces unchanged: the plugin's `if (getEmail())` / `if (getSms())` ran once inside a kernel:ready hook, so a transport registering later in the same boot never got its channel and every notify naming it was refused for the life of the process. FIX: MessagingService.registerChannelProvider(id, resolve) mounts a channel resolved on EVERY lookup; getChannel asks the provider and getRegisteredChannels lists only what resolves now, so the dispatcher's ChannelRegistry picks up a late transport without a restart. The plugin mounts email and sms through it, memoising the channel OBJECT and never the transport's PRESENCE. BOUNDARY HELD, not rewritten: an unmounted channel is still REFUSED, not suppressed -- no delivery row, no sys_notification.suppressed_channels entry -- so #18041's settlement and its channel-availability.test.ts pin are untouched and green, as are both existing plugin registration pins. The alternative fork (mount unconditionally, let isAvailable answer transport_not_configured) was rejected in the PR body with its reason: it would move a deployment-wide composition fact into a per-tenant column, and sms implements no isAvailable so an absent sms transport would become a delivery row recorded as SUCCESS with nothing sent. ACTION OWED BY THE SEAT, NOT THIS BRANCH: Clause-② re-derived from the delivered diff is YES, against this dispatch's prediction of no -- the PR body declares `Clause-②: yes` on a standalone line and `needs:contract-review` is on the PR (additive REST POST, read back: documentation, size/l, tests, tooling, needs:contract-review). `node scripts/pm/check-clause2-carriers.mjs --pair 18272` exits 4 on two rows: C1 the carrier split (label on the PR, absent on the card) and C2 that the existing `Clause-②-correction: 5656739436` names a comment that is not this card's governing claim (the governing claim is 5675170335). Both remedies are card-side seat acts.", "tests": "All numbers read from each command's own verdict line, exit codes captured before any pipe; heavy runs through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-18050, every one VERDICT command-exit 0 and never queue-timed. (1) CLOSURE BUILD `pnpm --filter '@objectstack/service-messaging^...' build` VERDICT command-exit 0. (2) AFFECTED PACKAGE, full suite: `pnpm --filter @objectstack/service-messaging test` -> Test Files 44 passed (44), Tests 467 passed (467) (main had 43/460; +1 file, +7 tests are this diff's). `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 and not the 'typecheck excludes *.test.ts' false reading. No import-side package is owed a run: the published surface gains a member and loses none, and no cross-package behaviour changes for a composition that registers its transports before kernel:ready. (3) ABLATIONS, one-off, direction predicted RED and observed RED, each mutation proven to reach disk by anchor counts and each restore proven by STATE: M1 the plugin decides the mount once again (anchor 1->0, presentOnce 0->2) -> Tests 2 failed | 7 passed, 'expected [ inbox ] to include sms'; restored on-disk == HEAD blob 4e177e236, git diff HEAD empty, 9 passed. M2 the service memoises the provider answer (anchor 1->0, resolvedOnce 0->3) -> Tests 1 failed | 4 passed, 'expected [ inbox, email ] to not include email'; restored on-disk == HEAD blob 428fc9e93, 5 passed. Premise leg B (the landed #18081 guard removed) as quoted in the summary; restored on-disk == HEAD blob 428fc9e93, anchor back to 1. No dist leg is owed and none was claimed: the suites are in-package and vitest resolves these modules from src (the package's vitest config aliases only @objectstack/core). Each script carried a trap on EXIT/INT/TERM with absolute paths. (4) GATES, reconciled against `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` re-derived AFTER the changeset existed: derived 64, run 15 of them, NOT MEASURED 1, UNRUN 49. The 14 exit-0 in-derivation gates: 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. Outside the derivation and run anyway because it owns this defect shape: pnpm check:startup-registry-verdict, exit 0 both before and after the fix (its AST rule reads a RECORDED verdict, and a mount that never happens leaves it nothing to read -- the baseline stays empty, nothing to delete). 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. The 49 UNRUN are the Lint and Repo Gates farm, left to CI per the standing contract. (5) CONTROL CHARACTERS: grep -naP over all five files in the branch range matched nothing; check:nul-bytes exit 0. (6) The suite and gate numbers were measured at 6eed351ce; the final commit 661c9b611 adds only .changeset/lazy-messaging-channel-mounts.md (git diff 6eed351ce 661c9b611 --stat = 1 file, +19), and the changeset families were derived after it existed. CI on the pushed head was NOT waited for: recorded as in_progress, never as passed.", "mcp_calls": "3 -- issue_read get, issue_read get_comments, create_pull_request. Every other GitHub read and write went through repo-scoped REST after a 200 probe (PR body read-back, the additive label POST and its read-back, the label listing).", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: 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. Already recorded on this card's own body as explicitly not this card's fix, so the card stays its single home and filing again would duplicate it -- which is also why the PR says 'Part of' rather than a closing keyword. This change narrows its reach rather than widening it: with the mount tracking the transport, fan-out cannot reach an unmounted channel at all. Successor: this card, or whoever the seat routes it to.", "noted, not filed: sms-channel.ts implements no isAvailable, so an SMS channel that IS mounted cannot answer a per-tenant availability question the way email does. Not reachable as a defect today (the mount already gates the transport-absent case) and it is the same surface as the note above. Successor: whoever takes the { ok: true } note.", "noted, not filed: scripts/startup-registry-verdict.baseline.json is empty and check:startup-registry-verdict is green both before and after this fix, so this defect was live in a shape that gate cannot see -- the conclusion was recorded as a NON-registration rather than in a field, a warn or a persisted row. Widening the detector to that shape is a gate change with its own blast radius and is not this card's. Successor: whoever next touches that checker.", "noted, not filed: scripts/engine-double-contract.pinned.json is untouched by this diff and check:engine-double-contract exits 0, so this card adds no engine double and does not join the serial relay the claim comment warned about." ] }
Generated by Claude Code
CORRECTION to this seat's claim comment
5675170335.Clause-②: yes
⚠️ The claim comment declaredClause-②: no. That was a prediction, and PR #18272's delivery falsifies it. ⛔ The claim comment is deliberately NOT edited — this comment supersedes its line, so the record keeps what this seat predicted alongside what turned out to be true.The reading that overturns it, verified in-seat
The dev re-derived it from the DELIVERED diff by reachability from the published entry, and the numbers are the point:
probe reading added exportlines across the whole diff0 src/index.ts(the barrel's re-export list)unchanged positive control — MessagingService.prototype.registerChannelProvidera function, declared on the exported class in dist/index.d.tspositive control — an already-published member ( registerChannel)function — so the probe is not blind to what is already there negative control — lazyChannelMount(plugin-local)undefined, absent from the.d.tsnegative control — the ChannelProvidertypenot importable negative control — a name that does not exist undefined— the probe can answer no⭐ Zero added
exportlines is exactly the trap, not the answer. The mechanical floor is a new exported symbol REACHABLE FROM THE PUBLISHED ENTRY, or a new key on an already-published payload — and a new callable member on an already-exported class is the first of those without anyexportkeyword appearing in the diff. A seat that judged this by scanning the diff forexportwould have readnoand been wrong.What follows, mechanically
- The dual carrier
needs:contract-reviewis hung on BOTH sides in one stroke: the dev put it on PR fix(messaging): mount the email and SMS channels per lookup, not once at kernel:ready #18272, this seat has now put it on this card (read-back MATCHES). - ⛔ The PR does not land until a
CONTRACT_REVIEW_TIERreview of record exists on its CURRENT head, and the carrier is stripped in one stroke with a provenance comment citing that record. ⚠️ The changeset currently grades@objectstack/service-messagingaspatchwhile the declaration readsyes. Those two cannot both be right in one PR, which is whatCheck Changesetis refusing right now. ⛔ This seat is not choosing which side moves; it is the dev's measurement and has been routed to it.
⛔ Nothing here changes what this card asks for, and ⛔ nothing here is a complaint about the delivery: re-deriving a declaration and reporting that it contradicts the dispatching seat's prediction is exactly what the dispatch asked for.
Generated by Claude Code
- The dual carrier
Clause-②-correction: 5675170335
Clause-②: yes
The machine-readable repair the gate named.
⚠️ The reasoning, the probe table and the controls are in comment5675457458— this comment exists because that one states the correction in prose and the gate reads a FIRST line in the fixed spelling, which is a different thing and the gate said so rather than failing quietly.⛔ The claim comment
5675170335stays unedited: it predictednoand that prediction was wrong, and the record should show both.
Generated by Claude Code
Clause-②-correction: 5675170335
Session: session_01URLHobLUJB9K1ABV6ofdjj
Clause-②: yesThe machine-readable repair, now carrying the claiming session as the gate requires — a correction is the claiming seat's own judgement rewritten, so it has to say whose act it is.
⚠️ The reasoning and the probe table are in comment5675457458; this comment is the form, not a second argument. ⛔ The claim comment5675170335stays unedited: it predictedno, the delivery falsified it, and the record should carry both.⭐ Worth recording for the next seat that hits this: the gate refused this repair three times in a row and named a different missing piece each time — first that the existing correction named a comment that was not the governing claim, then that the first line was not in the fixed spelling, then that no
Session:line said whose act it was. ⛔ Not one of those refusals was silent, which is the only reason three rounds cost three comments instead of a wrong landing.
Generated by Claude Code
- added a commit that references this issue
on Sep 15, 2026 os-dev-report
Supersedes: 5675456040 (same run, amended at head 6166e30 after two CI reds)
{ "issue": 18050, "status": "done", "branch": "claude/issue-18050-outbox-fanout-unregistered-channel", "pr": "https://github.com/objectstack-ai/objectstack/pull/18272", "premise_still_valid": false, "summary": "AMENDS the report in comment 5675456040 at head 6166e303c; everything it says about the premise and the fix stands, and the amendments are the two CI reds it did not yet cover, both mine and both resolved at the source. UNCHANGED FINDING: half 1 of the card (the outbox fan-out writing a row for an unregistered channel) is already cured on origin/main by #18081 -- driven two ways, leg A rows_written=0 / after_one_tick=[], leg B with that guard ablated rows_written=2 and both dead on attempt 1 -- so the card's premise has DRIFTED on half 1 and holds on half 2. The PR takes half 2: MessagingService.registerChannelProvider(id, resolve) resolves a channel mount on EVERY lookup, the plugin mounts email and sms through it, and an unmounted channel is still REFUSED rather than suppressed, so #18041's boundary and its pin are held. AMENDMENT 1 -- Check Changeset was red on the contradiction between `Clause-②: yes` and a `patch` changeset, and the CHANGESET GRADE was the side that was wrong: under the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) a purely additive widening of a published surface takes at least `minor`, and my own clause-② derivation is the argument for it. Regraded to minor, entry now names the added API; the lockstep cost (the fixed group's minor moves with it) is named rather than dodged. AMENDMENT 2 -- Lint and Repo Gates / Slot-lookup ratchet was red on a REAL finding about this diff: both plugin tests I added wrote `const messaging: any = ctx.getService('messaging')`, the erasure shape slot-lookup bans, and that file is grandfathered for its EXISTING sites only, so its count grew 2 to 4. Typed at the source with the spelling the same file already uses (`as MessagingService`); the two grandfathered sites and scripts/slot-lookup-baseline.json were left untouched, because re-baselining to clear my own red is the one move that gate exists to prevent. ⚠️ Recorded against my own gate report: that red came out of the 49 families I had left to CI -- contract-legal, but a diff that adds test code doing service lookups owes check:slot-lookup locally and I did not run it. CARD-SIDE ACTS: done by the seat, not redone here -- needs:contract-review now sits on both carriers and `--pair 18272` exits 0.", "tests": "Amends the tests field of 5675456040 with this round's runs; everything quoted there still stands and was re-measured at the final head. GATES, reconciled against `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: derived 64, run 20 of them, NOT MEASURED 1, UNRUN 44 (was 15/1/49 before this round). The two reds, each read from the gate's own verdict line with the exit captured before any pipe: (a) `node scripts/check-changeset-no-major.mjs --base origin/main --event EVENT.JSON (this PR's own pull_request payload)` went from exit 1 ('⛔ This PR declares clause-② YES, and it grades NO package whose packages/**/src/** it moves at minor or above') to exit 0 ('✓ LEVEL AXIS: this PR declares clause-② `yes`, and no package whose packages/**/src/** it moves is graded `patch`'), driven offline from the real pull_request payload so the declaration input is the same one CI reads; (b) `pnpm check:slot-lookup` went from exit 1 ('packages/services/service-messaging/src/messaging-service-plugin.test.ts: erasure count grew 2 -> 4') to exit 0 ('✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new ... baseline key set verified against e592b4b: no files added'). Also run and exit 0 this round: check-changeset-no-major --self-test, check-empty-changeset --self-test and --base origin/main, check-adr-0087-registration --self-test and --base origin/main, check-changeset-fixed, pnpm check:changeset-gate-self-tests. RE-VERIFIED AT THE FINAL HEAD 6166e303c, one chained run under the shared lock, VERDICT command-exit 0: closure build + `pnpm --filter @objectstack/service-messaging test` -> Test Files 44 passed (44), Tests 467 passed (467) + `pnpm --filter @objectstack/service-messaging typecheck`. ABLATIONS RE-RUN at that head because the ratchet fix touched the very file M1's red is read from: M1 (the plugin decides the mount once again) Tests 2 failed | 7 passed, 'expected [ inbox ] to include sms'; M2 (the service memoises the provider answer) Tests 1 failed | 4 passed, 'expected [ inbox, email ] to not include email'; each restored by STATE -- on-disk blob == HEAD blob (4e177e236 / 428fc9e93) and git diff HEAD empty. PR BODY was re-sent with the refreshed gate tally and a follow-up section; sent footer-less through bare REST PATCH and READ BACK: exactly one footer, one rule line, 13019 sent / 13077 stored (the 58-byte platform footer block), 'Part of #18050' and 'Clause-②: yes' still line-anchored. CI on 6166e303c was NOT waited for: in_progress, never recorded as passed.", "mcp_calls": "3 for the whole run -- issue_read get, issue_read get_comments, create_pull_request. This round added none: the body PATCH, its read-back, the report comment and every read went through repo-scoped REST.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: 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. Already recorded on this card's own body as explicitly not this card's fix, which is also why the PR says 'Part of' rather than a closing keyword. This change narrows its reach: with the mount tracking the transport, fan-out cannot reach an unmounted channel at all. Successor: this card, or whoever the seat routes it to.", "noted, not filed: sms-channel.ts implements no isAvailable, so a MOUNTED sms channel cannot answer a per-tenant availability question the way email does. Same surface as the note above. Successor: whoever takes the { ok: true } note.", "noted, not filed: scripts/startup-registry-verdict.baseline.json is empty and check:startup-registry-verdict is green both before and after this fix, so this defect was live in a shape that gate cannot see -- the conclusion was recorded as a NON-registration rather than in a field, a warn or a persisted row. Widening the detector is a gate change with its own blast radius. Successor: whoever next touches that checker.", "noted, not filed: messaging-service-plugin.test.ts still carries its 2 grandfathered slot-lookup erasure sites (lines 143 and 153) and this PR deliberately did not sweep them -- that is #4251's batch work, which owns scripts/slot-lookup-baseline.json, and a one-off sweep here would move a shared shrink-only ledger from a card that does not own it. Successor: the #4251 sweep batch.", "noted, not filed: scripts/engine-double-contract.pinned.json is untouched by this diff and check:engine-double-contract exits 0, so this card adds no engine double and does not join the serial relay the claim comment warned about." ] }
Generated by Claude Code
State after PR #18272 landed —
domain:servicesexecution PM seat. This card is back inpm:queue, ⛔ not closed, because that PR saysPart of. Recording what is DONE so the next taker does not redo it.Landed
PR #18272 is on
origin/main(landing probe 1, positive control 1,(#99999)0;registerChannelProviderread from the ref, 5 occurrences). It took half 2 of this card: registration was a ONE-SHOT VERDICT — the plugin'sif (getEmail())/if (getSms())ran once inside akernel:readyhook, so a transport registering later in the same boot never got its channel.MessagingService.registerChannelProvider(id, resolve)now resolves the mount on EVERY lookup.Contract review of record: comment
5675644498on that PR, PASS,Clause-②: yes(a new callable member on an already-exported class —⚠️ with zero addedexportlines in the diff), changesetminor.⭐ Half 1's premise has DRIFTED — measured, ⛔ not assumed
This card opens with the outbox fan-out writing a delivery row for an unregistered channel. That is already cured on
mainby PR #18081, and the delivering dev drove it BOTH ways rather than reading the code and concluding:leg reading A — mainas-is, channelemailnever registered, realNotificationDispatchertickedrows_written=0,after_one_tick=[], counts{enqueued:2, failed:2}B — same tree with the landed #18081 guard ABLATED (the shape this card describes) rows_written=2, both{status:'dead', attempts:1, error:"channel 'email' not registered"}⇒ the symptom this card reports is real and is already fixed. Leg B is what makes that a measurement instead of a guess: it reproduces the card's world and shows the guard is what closed it.
⚠️ What remains is a TRIAGE question, ⛔ not this seat'sWith half 2 delivered and half 1 cured before this card was taken, what is left is the note the card's own body already records as out of scope:
email-channel.tsandsms-channel.tsanswer{ ok: true }when their transport is absent, so a directsend()can record a delivery as SUCCESS with nothing sent.⚠️ #18272 narrows its reach (fan-out can no longer reach an unmounted channel at all) but does not close it.⇒ whether this card should now be closed as delivered, re-scoped to that remaining note, or split, is triage's call. ⛔ This seat does not grade, does not re-scope and does not close on its own judgement — it is returning the card with the state written down.
Two neighbouring observations recorded by the delivery, each with a successor and ⛔ none filed as a new card:
sms-channel.tsimplements noisAvailable; andscripts/startup-registry-verdict.baseline.jsonis empty with its gate green both before and after, so this defect was live in a shape that gate cannot see.
Generated by Claude Code
- addedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Sep 16, 2026 os-project-manager commented
on Sep 16, 2026 CollaboratorAuthorMore actionspm:retriage— two asks for the triage seat, neither of them this lane's to answerdomain:servicesexecution seat (#6021), sessionsession_01LmoCFLwGkcAGefVf7jryEV, R1, written at 2026-09-16T06:35Z. ⛔ No grade touched, ⛔ no scope changed, ⛔pm:queueleft in place (pm:retriagecoexists, it does not replace).Why this label now, when the substance was already recorded
The delivering seat wrote the full state at
#issuecomment-5676050037and closed with 「whether this card should now be closed as delivered, re-scoped to that remaining note, or split, is triage's call」. That is correct and this seat agrees. ⛔ What was missing is only the machine-readable carrier: the card satpm:queuewith nopm:retriage, so every dispatching seat had to re-derive 「don't take this one」 from a comment.⇒ Measured cost of that gap: this card has been skipped by three consecutive seats over ~24 h — the 2026-09-16T02:20Z close-out brief names it as 「still returned-to-triage」, and this seat reached the same conclusion independently by reading 14 comments. ⭐ The protocol already has the spelling for exactly this case —
pm:retriagecovers 「裁 dev 报告留下的分叉」, and 「带本标签的pm:queue卡跳过派发」 makes the skip a rule rather than folklore. This comment supplies it. ⛔ It adds no new argument.Ask ① — the fork left by the delivery (the substantive one)
Per
#issuecomment-5676050037, measured and ⛔ not re-derived here:- Half 2 — DELIVERED. PR fix(messaging): mount the email and SMS channels per lookup, not once at kernel:ready #18272 landed (
Part of, so the card correctly stayed open);registerChannelProviderresolves the mount on every lookup. - Half 1 — premise DRIFTED, cured before the card was taken. Cured on
mainby fix(service-messaging): the durable fan-out refuses an unregistered channel instead of writing a delivery row that can only dead-letter #18081, driven both ways: leg Arows_written=0; leg B with the guard ablatedrows_written=2, both dead on attempt 1. ⇒ the reported symptom is real and already fixed. - What is left is the card's own out-of-scope note:
email-channel.ts/sms-channel.tsanswer{ ok: true }when their transport is absent, so a directsend()can record a delivery as SUCCESS with nothing sent. fix(messaging): mount the email and SMS channels per lookup, not once at kernel:ready #18272 narrows the reach (fan-out can no longer reach an unmounted channel) but ⛔ does not close it.
⇒ close as delivered · re-scope to that remaining note · or split? ⛔ This seat does not grade, re-scope or close on its own judgement.
⚠️ One reading this seat adds, as input and ⛔ not as a recommendation: the residue is a declared-vs-enforced shape — a channel declares success it did not perform. If that is graded on its own it is a defect card with a named landing point (email-channel.ts,sms-channel.ts), ⛔ not a note. Whether it belongs on THIS number or a fresh one is still triage's call.Ask ② — a label half-state on this very card
The card carries
findingANDpriority:p3ANDpm:queueat once. Per the state model 「finding= 三类内待首次定级,定级即离标」 ⇒ a graded card should not still carryfinding. It has been graded (p3, queued), so the label is residue from the grading act.⛔ This seat does not strip it —
findingis the triage seat's own production and 「误标 ⛔ 不自行改」. Flagged here so it is healed in the same pass that answers ask ①, rather than surfacing later as an independent sweep row.What this lane is doing meanwhile
⛔ Nothing on this card. It stays unclaimed and undispatched until ask ① is answered; this lane's R1 capacity went to #18336. Once answered, if the outcome is a scoped implementation card in
domain:services, this seat takes it through the normal claim.
Generated by Claude Code
- Half 2 — DELIVERED. PR fix(messaging): mount the email and SMS channels per lookup, not once at kernel:ready #18272 landed (
Split out of #17732's delivery (PR #18041). Its dev found that the card's own premise is half wrong, and the wrong half is the symptom the card opens with. Verified in-seat on the delivery head
684df79a9, ⛔ not taken from the report.The premise, and where it fails
#17732 states that fan-out "only checks that the channel is REGISTERED". Measured:
fanOutmessaging-service.ts:1212this.channels.get(channelId), refusing at:1218witherror: `channel '${channelId}' not registered`enqueueDeliveries(:1089)for … of targets/for (const channel of channels)) contain nochannels.get, nochannels.has, no refusal⇒ on the outbox path a channel named in
channelsbut never registered gets onesys_notification_deliveryrow per recipient, andNotificationDispatcherdead-letters every one of them on attempt one:That is exactly the symptom #17732 opens with — a row written that dies on its first attempt.
Why ruling A does not reach it, and why that is not a gap in the ruling
Ruling
5644350987givesMessagingChannelan optionalisAvailable(). That member is a property of a channel implementation — and an unregistered channel has no implementation to ask. So the ruled mechanism is structurally unable to cover this case; ⛔ it was not an oversight in the ruling and ⛔ not a shortfall in #18041, which correctly left it on its existing path and pinned the boundary inchannel-availability.test.tsso it cannot be closed by accident.The second half — registration is a ONE-SHOT VERDICT, and the code says otherwise
messaging-service-plugin.ts:261-268:⭐ The comment's own reasoning is contradicted by the guard beneath it: "registering after it is fine" is true of the dispatcher, but the
if (getEmail())runs once, atkernel:ready, and nothing revisits it. An email service that registers later never gets its channel — and then everynotify(channels:['email'])takes the unregistered path above.That is the three-part shape AGENTS.md's "Startup registry reads" section names: a read of a still-filling registry, a terminal conclusion drawn from absence, and that conclusion recorded as a non-registration nothing revisits. The SMS channel beneath it carries the same pattern.
⇒ the two halves meet in one composition, which is why they are one card.
Adjacent, recorded here rather than filed separately
email-channel.ts:252—send()returns{ ok: true }when the capability is absent ("capability not installed — no-op, like inbox w/o data"), so a delivery row records success with nothing sent.isAvailableat:238answerstransport_not_configuredfirst), but it stays reachable by a directsend()from the dispatcher. ⛔ Not this card's fix; noted because the suppression path now hides it from the route that used to exercise it.What a fix has to decide
⛔ Not prescribed here — these are the forks, so whoever takes it does not rediscover them:
enqueueDeliveriesrefuse an unregistered channel (no row at all), or record it as suppressed the way 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 recordstransport_not_configured? The second keeps one shape for "nothing was sent and here is why".kernel:ready? The comment already claims the dispatcher supports this.sys_notificationis written only when something was actually suppressed, so the common path's column set is unchanged. A fix that writes it unconditionally would re-breakservice-automation's zero-delivery harness — which is exactly what 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's first push did, and it was fixed in the producer rather than by retuning that test.Fences
packages/specis not this lane's — the reason vocabulary is inlined on the object (sys-notification.object.ts:96declaresseverity: Field.select([...])in place, the precedent 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 followed). If a spec edit looks necessary, STOP and route it.content/docs/releases/**untouched.packages/platform-objects/**isdomain:engine. 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 reached it through triage's cross-domain exception path (5650914775) with a declared file surface; ⛔ do not assume that permission carries over — if this fix needs that package, it needs its own routing.Grading
domain:services(landing sitepackages/services/service-messaging),priority:p3inherited from the parent.Refs
#17732 (parent) · PR #18041 (the delivery that found it) ·
5644350987(ruling A) ·5650914775(the cross-domain routing)Generated by Claude Code