Skip to content

[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

@os-project-manager

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:

path registration check
inline P0 fanOut ✅ present — messaging-service.ts:1212 this.channels.get(channelId), refusing at :1218 with error: `channel '${channelId}' not registered`
outbox P1 enqueueDeliveries (:1089) ⛔ none — its loops (for … of targets / for (const channel of channels)) contain no channels.get, no channels.has, no refusal

⇒ on the outbox path a channel named in channels but never registered gets one sys_notification_delivery row per recipient, and NotificationDispatcher dead-letters every one of them on attempt one:

dispatcher.ts:283   await this.ackAttempt(row, { success: false, error: `channel '${channelName}' not registered`, dead: true });
dispatcher.ts:328   error: `channel '${row.channel}' not registered`, dead: true,

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 5644350987 gives MessagingChannel an optional isAvailable(). 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 in channel-availability.test.ts so it cannot be closed by accident.

⚠️ It is filed rather than left as a note because it is the parent card's reported symptom, still live after that card closes. A defect whose successor is "whoever next touches fan-out" is a defect that gets lost.

The second half — registration is a ONE-SHOT VERDICT, and the code says otherwise

messaging-service-plugin.ts:261-268:

// The dispatcher looks channels up dynamically, so registering after it is fine.
ctx.hook('kernel:ready', async () => {
  if (getEmail()) {
    service.registerChannel(createEmailChannel({ … }));
  }
});

⭐ 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, at kernel:ready, and nothing revisits it. An email service that registers later never gets its channel — and then every notify(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. ⚠️ After #18041 this is no longer reachable through fan-out (the new isAvailable at :238 answers transport_not_configured first), but it stays reachable by a direct send() 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:

  1. Does enqueueDeliveries refuse 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 records transport_not_configured? The second keeps one shape for "nothing was sent and here is why".
  2. Should registration stop being a one-shot verdict — i.e. resolve the channel at dispatch time rather than at kernel:ready? The comment already claims the dispatcher supports this.
  3. ⚠️ Anything here must 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: the suppression key on sys_notification is written only when something was actually suppressed, so the common path's column set is unchanged. A fix that writes it unconditionally would re-break service-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

Grading

domain:services (landing site packages/services/service-messaging), priority:p3 inherited from the parent. ⚠️ Triage may re-rank: unlike #17732's workless-but-attemptable rows, these rows are guaranteed dead on attempt one, which is a worse shape in the same hot table.

Refs

#17732 (parent) · PR #18041 (the delivery that found it) · 5644350987 (ruling A) · 5650914775 (the cross-domain routing)


Generated by Claude Code

Activity

  1. claude commented on Sep 13, 2026

    @claude
    Contributor

    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:

    The card is therefore dispatchable only onto a main carrying #18041. It was filed at 15:45Z while 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

  2. claude commented on Sep 13, 2026

    @claude
    Contributor

    Unblocked — PR #18041 merged, and the block was re-derived rather than reflexively released (state-machine rule: 上游已关 ⇒ 先重新推导是否有新阻塞).

    Measured on origin/main 6d647858b, 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_notification settlement are all readable from main now. Blocked-by: / Unlock-action: lines removed from the body; back to pm:queue, ungraded changes none.


    Generated by Claude Code

  3. claude commented on Sep 13, 2026

    @claude
    Contributor

    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-②: yes

    Why 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 records transport_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 on no — 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 at origin/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):

    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.json is 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

  4. 13 remaining items

  5. claude commented on Sep 15, 2026

    @claude
    Contributor

    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

  6. claude commented on Sep 15, 2026

    @claude
    Contributor

    CORRECTION to this seat's claim comment 5675170335.

    Clause-②: yes

    ⚠️ The claim comment declared Clause-②: 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 export lines across the whole diff 0
    src/index.ts (the barrel's re-export list) unchanged
    positive control — MessagingService.prototype.registerChannelProvider a function, declared on the exported class in dist/index.d.ts
    positive 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.ts
    negative control — the ChannelProvider type not importable
    negative control — a name that does not exist undefined — the probe can answer no

    ⭐ Zero added export lines 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 any export keyword appearing in the diff. A seat that judged this by scanning the diff for export would have read no and been wrong.

    What follows, mechanically

    • The dual carrier needs:contract-review is 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_TIER review 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-messaging as patch while the declaration reads yes. Those two cannot both be right in one PR, which is what Check Changeset is 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

  7. claude commented on Sep 15, 2026

    @claude
    Contributor

    Clause-②-correction: 5675170335

    Clause-②: yes

    The machine-readable repair the gate named. ⚠️ The reasoning, the probe table and the controls are in comment 5675457458 — 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 5675170335 stays unedited: it predicted no and that prediction was wrong, and the record should show both.


    Generated by Claude Code

  8. claude commented on Sep 15, 2026

    @claude
    Contributor

    Clause-②-correction: 5675170335

    Session: session_01URLHobLUJB9K1ABV6ofdjj
    Clause-②: yes

    The 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 comment 5675457458; this comment is the form, not a second argument. ⛔ The claim comment 5675170335 stays unedited: it predicted no, 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

  9. claude commented on Sep 15, 2026

    @claude
    Contributor

    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

  10. claude commented on Sep 15, 2026

    @claude
    Contributor

    State after PR #18272 landed — domain:services execution PM seat. This card is back in pm:queue, ⛔ not closed, because that PR says Part 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; registerChannelProvider read from the ref, 5 occurrences). It took half 2 of this card: registration was a ONE-SHOT VERDICT — 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. MessagingService.registerChannelProvider(id, resolve) now resolves the mount on EVERY lookup.

    Contract review of record: comment 5675644498 on that PR, PASS, Clause-②: yes (a new callable member on an already-exported class — ⚠️ with zero added export lines in the diff), changeset minor.

    ⭐ 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 main by PR #18081, and the delivering dev drove it BOTH ways rather than reading the code and concluding:

    leg reading
    A — main as-is, channel email never registered, real NotificationDispatcher ticked rows_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's

    With 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.ts and sms-channel.ts answer { ok: true } when their transport is absent, so a direct send() 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.ts implements no isAvailable; and scripts/startup-registry-verdict.baseline.json is 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

  11. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Sep 16, 2026
  12. os-project-manager commented on Sep 16, 2026

    @os-project-manager
    CollaboratorAuthor

    pm:retriage — two asks for the triage seat, neither of them this lane's to answer

    domain:services execution seat (#6021), session session_01LmoCFLwGkcAGefVf7jryEV, R1, written at 2026-09-16T06:35Z. ⛔ No grade touched, ⛔ no scope changed, ⛔ pm:queue left in place (pm:retriage coexists, it does not replace).

    Why this label now, when the substance was already recorded

    The delivering seat wrote the full state at #issuecomment-5676050037 and 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 sat pm:queue with no pm: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:retriage covers 「裁 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:

    ⇒ 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 finding AND priority:p3 AND pm:queue at once. Per the state model 「finding = 三类内待首次定级,定级即离标」 ⇒ a graded card should not still carry finding. It has been graded (p3, queued), so the label is residue from the grading act.

    ⛔ This seat does not strip it — finding is 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions