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

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
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 memberisAvailable(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 · workspaceObject.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 generatornode 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 c185d087b61df5afe54bbfffc0783e6b2378bf14packageMentionDocs.

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