Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
# Add @suaveplan/broadcast-channel `ask()` request/response
**Package**: `@suaveplan/broadcast-channel`
**Status**: PROPOSED
**Type**: capability-extension
**Spec delta**: ./specs/broadcast-channel/spec.md
## Why
The initial `@suaveplan/broadcast-channel` ship (`openspec/archive/2026-05-19-add-broadcast-channel/`)
explicitly deferred the `ask()` request/response helper under "Out of scope":
> `ask()` request/response — not in the four mandated gap fixes; consumers can layer correlation-ID
> on top of `send`/`on`. Will land in a follow-up change with `BROADCAST_CHANNEL.ASK_TIMEOUT` code.
Consumers (cross-tab leader queries, per-tab capability probes, "who-has-feature-X?" fan-outs)
currently have to re-implement correlation-ID + timeout plumbing on top of `send` / `on`. That is
exactly the duplication the package exists to eliminate, and it is the one §4 enhancement that is
small, self-contained, and trivially testable with deterministic injectables. This change ships
ONLY `ask()` + `onAsk()`. Every other §4 item (leader-election strategies, replay buffer, CRDT,
encryption, compression, namespacing, dev overlay, presence hooks) remains deferred to its own
dedicated change.
## What changes
- ADDED: `TypedBroadcastChannel.ask<TReq, TResp>(handlerKey, payload, opts?) → Promise<TResp>` with
injectable timeout, correlation IDs, and optional `requireResponder` guard.
- ADDED: `TypedBroadcastChannel.onAsk(handlerKey, handler)` — single-handler-per-key registration.
- ADDED: Two new wire-frame variants — `ask` (carrying `correlationId`, `handlerKey`, `payload`,
`expiresAt`) and `ask-response` (carrying `responseTo`, `handlerKey`, `outcome: "ok" | "error"`,
`payload`).
- ADDED: Frame schema extended via discriminated union — `ChannelMessageSchema` is widened, not
replaced; all existing `send` / `on` / heartbeat paths remain bit-compatible.
- ADDED: Three new error codes — `BROADCAST_CHANNEL.ASK_TIMEOUT`,
`BROADCAST_CHANNEL.ASK_NO_RESPONDER`, `BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE` — each thrown by
≥1 production path.
- ADDED: Three new OTel counters — `channel.ask.sent{key}`, `channel.ask.responded{key,outcome}`,
`channel.ask.timeout{key}` — under the existing `@suaveplan/broadcast-channel` meter.
- ADDED: Injectable `TimersLike` on `TypedBroadcastChannelOptions` (default backed by global
`setTimeout` / `clearTimeout`) so timeout tests run deterministically against a FakeClock-driven
timer pair.
## Out of scope
- Multi-responder fan-out collection (`askMany()` / `askAll()`).
- Streaming / progressive responses (responses are one-shot per `correlationId`).
- Leader-election strategies (round-robin, active-tab-wins).
- Replay buffer, CRDT adapter, encryption hook, compression, namespacing, dev overlay,
`onTabFocus` / `onTabBlur` hooks.
## Source mapping
This change derives from `../suaveplan/enterprise/broadcast-channel.md` §4.1 and the initial
ship's "Out of scope" deferral list.
| Requirement | Source lines / symbol | Notes |
|---|---|---|
| `ask()` request/response | enterprise.md §4.1 ("Request/response (`ask`) primitive with correlation ID, timeout, and typed response.") | §4 enhancement #1, deferred from initial ship |
| `ASK_TIMEOUT` code | initial proposal "Out of scope" line referencing `BROADCAST_CHANNEL.ASK_TIMEOUT` | explicit follow-up |
| Single-handler `onAsk` registration | genesis design choice — multi-responder is its own future change | genesis-original |
| Three OTel counters | genesis cross-cutting (rule 6 of CLAUDE.md — every hot path gets a counter) | genesis-original |
| Injectable `TimersLike` | `@suaveplan/types::TimersLike`; CLAUDE.md rule 2 (no real timers in tests) | genesis cross-cutting |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `requireResponder` config flag → `ASK_NO_RESPONDER` | Genesis safety knob: callers that *must* have a peer answer (e.g. leader-only queries) need a distinct error code from a generic timeout to drive retry policy. |
| 100% Istanbul coverage on touched modules | Genesis-wide policy (`AGENTS.md` §7) |
| `@suaveplan/testing/runner` test imports | Genesis-wide rule 11 / CLAUDE.md |
## Verification approach
- `src/ask/ask.test.ts` — `ask()` happy path round-trip with two `TypedBroadcastChannel`s sharing a
fake transport pair; correlation-ID isolation under concurrent calls; timeout via FakeClock-driven
`TimersLike`; `requireResponder` flag toggling `ASK_NO_RESPONDER` vs. `ASK_TIMEOUT`; handler-thrown
errors propagate as `ask-response` `outcome: "error"` and reject the caller; cleanup on
`close()` rejects all pending asks.
- `src/ask/responder.test.ts` — `onAsk` registration; duplicate registration throws
`ASK_HANDLER_DUPLICATE`; unregister via returned disposer; non-matching `ask` frames are dropped
silently.
- `src/schemas/messages.test.ts` — widened union accepts both legacy frames and new `ask` /
`ask-response` variants; malformed `ask` (missing `correlationId`) rejected.
- `src/otel/metrics.test.ts` — three new counter names observed via in-memory `MeterProvider`.
- `src/errors/codes.test.ts` — registry size + 7-code shape; every new code thrown by a real path.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/broadcast-channel` to enumerate. The package is browser-tier
and has no internal dependents on genesis at the time of this change.
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/broadcast-channel` — package is
a leaf browser-tier addition that landed 2026-05-19; nothing else has wired against it yet).
## Migration impact
None — additive. Existing `send` / `on` / leader / heartbeat call sites are byte-compatible: the
frame schema widens via discriminated union, the `BROADCAST_CHANNEL_CODES` registry grows by three
codes (existing codes unchanged), and the OTel meter gains three counters (existing six unchanged).
No public types are renamed or removed.

View File

@@ -0,0 +1,167 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: broadcast-channel — add `ask()` request/response
**Package**: `@suaveplan/broadcast-channel`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: `ask()` correlated request/response
`TypedBroadcastChannel` SHALL expose `ask<TReq, TResp>(handlerKey: string, payload: TReq, opts?: AskOptions): Promise<TResp>`. Every call SHALL post an `ask` frame to the underlying transport carrying a fresh `correlationId` drawn from the injected `Rng`, the supplied `handlerKey`, the supplied `payload`, and an `expiresAt` timestamp computed as `clock() + opts.timeoutMs`. The returned promise SHALL resolve with the matching peer's response payload when an `ask-response` frame whose `responseTo` equals the issued `correlationId` is received before the timeout elapses, and SHALL reject with `BroadcastChannelAskTimeoutError` (code `BROADCAST_CHANNEL.ASK_TIMEOUT`) when the injected `TimersLike.setTimer` callback fires first.
#### Scenario: ask round-trips through a responder
- **WHEN** channel A calls `ask("ping", { n: 1 })` and channel B has registered `onAsk("ping", async ({ n }) => ({ n: n + 1 }))`
- **THEN** channel A's promise resolves with `{ n: 2 }` exactly once and channel B has posted exactly one `ask-response` frame whose `responseTo` matches the original `correlationId`
#### Scenario: ask defaults to a 5000 ms timeout
- **WHEN** `ask("nobody-home", null)` is invoked with no `opts.timeoutMs` and no peer answers within 5000 simulated ms
- **THEN** the promise rejects with code `BROADCAST_CHANNEL.ASK_TIMEOUT` and the underlying transport has emitted exactly one `ask` frame
#### Scenario: concurrent asks remain isolated by correlationId
- **WHEN** the asker fires two concurrent `ask("k", ...)` calls and the responder answers them in reverse order
- **THEN** each promise resolves with the response posted against its own `correlationId` and neither promise sees the other's payload
**Verified by:** `src/ask/ask.test.ts::"ask round-trips through a responder"` AND `src/ask/ask.test.ts::"defaults to a 5000 ms timeout when none supplied"` AND `src/ask/ask.test.ts::"keeps concurrent asks isolated by correlationId"`
---
### Requirement: `onAsk()` single-handler registration
`TypedBroadcastChannel` SHALL expose `onAsk<TReq, TResp>(handlerKey: string, handler: (payload: TReq, ctx: AskHandlerContext) => TResp | Promise<TResp>): () => void`. Only one handler MAY be registered per `handlerKey`; a second registration SHALL throw `BroadcastChannelAskHandlerDuplicateError` (code `BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE`) synchronously. The returned disposer SHALL remove the handler so the same key MAY be re-registered. Inbound `ask` frames whose `handlerKey` has no registered handler SHALL be dropped silently (no outbound `ask-response`, no listener error, no tab state mutation).
#### Scenario: duplicate registration throws
- **WHEN** a consumer calls `onAsk("dup", h1)` and then `onAsk("dup", h2)` without disposing the first
- **THEN** the second call synchronously throws an error whose `code` equals `BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE`
#### Scenario: disposer frees the key
- **WHEN** a consumer registers `onAsk("k", h)`, invokes the returned disposer, and then re-registers `onAsk("k", h2)`
- **THEN** the second registration succeeds and `h2` receives the next `ask("k", ...)` payload
#### Scenario: unregistered key drops the ask silently
- **WHEN** an inbound `ask` frame with `handlerKey: "unknown"` arrives at a channel that has no responder for `"unknown"`
- **THEN** no `ask-response` frame is posted and no error is surfaced on the receiver
**Verified by:** `src/ask/responder.test.ts::"throws ASK_HANDLER_DUPLICATE on second registration"` AND `src/ask/responder.test.ts::"disposer allows re-registration"` AND `src/ask/responder.test.ts::"unregistered handlerKey drops ask silently"`
---
### Requirement: `requireResponder` guard surfaces `ASK_NO_RESPONDER`
`AskOptions.requireResponder` SHALL default to `false` (timeout-only behavior — first response wins). When set to `true`, the controller SHALL track whether at least one peer has claimed the `handlerKey` (signalled by a `responder-claim` echo frame OR the eventual receipt of an `ask-response` from any peer). If the timeout fires AND no peer has claimed the key, the promise SHALL reject with `BroadcastChannelAskNoResponderError` (code `BROADCAST_CHANNEL.ASK_NO_RESPONDER`) instead of `ASK_TIMEOUT`. If at least one peer claimed the key but did not respond in time, the promise SHALL reject with `ASK_TIMEOUT` as normal.
#### Scenario: no peers, requireResponder rejects with ASK_NO_RESPONDER
- **WHEN** channel A calls `ask("orphan", null, { timeoutMs: 50, requireResponder: true })` with no peer registered
- **THEN** the promise rejects with code `BROADCAST_CHANNEL.ASK_NO_RESPONDER`
#### Scenario: requireResponder defaults to off
- **WHEN** `ask("orphan", null, { timeoutMs: 50 })` is called with no peer registered
- **THEN** the promise rejects with code `BROADCAST_CHANNEL.ASK_TIMEOUT` (not `ASK_NO_RESPONDER`)
**Verified by:** `src/ask/ask.test.ts::"requireResponder rejects with ASK_NO_RESPONDER when no peer claims the key"` AND `src/ask/ask.test.ts::"requireResponder defaults to off"`
---
### Requirement: Handler errors propagate to the asker
When the responder's `onAsk` handler throws or returns a rejected promise, the channel SHALL post an `ask-response` frame whose `outcome` is `"error"` and whose `payload` is a structured error projection (`{ message: string, code?: string }`). The asker's `ask()` promise SHALL reject with an `Error` whose `message` mirrors that projection.
#### Scenario: thrown handler error reaches the asker
- **WHEN** channel B's `onAsk("crash", ...)` handler throws `new Error("nope")`
- **THEN** channel A's `ask("crash", null)` promise rejects with an Error whose message contains `"nope"` and an `ask-response` frame with `outcome: "error"` was posted
**Verified by:** `src/ask/ask.test.ts::"handler-thrown error rejects the asker"`
---
### Requirement: `close()` cancels pending asks
When `TypedBroadcastChannel.close()` is called while one or more `ask()` promises are pending, every pending promise SHALL reject with `BroadcastChannelClosedError` (code `BROADCAST_CHANNEL.CHANNEL_CLOSED`) and every scheduled timeout SHALL be cleared via `TimersLike.clearTimer`. Subsequent `ask()` calls SHALL throw `BroadcastChannelClosedError` synchronously like the existing `send()` path.
#### Scenario: pending ask rejects on close
- **WHEN** `ask("slow", null, { timeoutMs: 10_000 })` is in flight and `channel.close()` is invoked
- **THEN** the promise rejects with code `BROADCAST_CHANNEL.CHANNEL_CLOSED` and no leaked timer remains scheduled in the injected `TimersLike`
**Verified by:** `src/ask/ask.test.ts::"close rejects pending asks with CHANNEL_CLOSED"`
---
### Requirement: Frame schema extended via discriminated union
`ChannelMessageSchema` SHALL accept the existing frame shape AND two new variants: `AskFrameSchema` with required fields `{ type: "ask", correlationId: string, handlerKey: string, payload: unknown, expiresAt: number, id: string, senderId: string, timestamp: number }`, and `AskResponseFrameSchema` with required fields `{ type: "ask-response", responseTo: string, handlerKey: string, outcome: "ok" | "error", payload: unknown, id: string, senderId: string, timestamp: number }`. Frames that match the existing variant continue to parse with no behavioral change. Frames that match neither variant continue to be dropped silently and increment `channel.message.dropped`.
#### Scenario: existing frames still validate
- **WHEN** the schema is fed the legacy `{ type: "greet", payload, id, senderId, timestamp }` shape
- **THEN** `safeParse` succeeds with the legacy interpretation
#### Scenario: ask frame validates only with correlationId
- **WHEN** an inbound frame has `type: "ask"` but is missing `correlationId`
- **THEN** `safeParse` fails and the channel drops the frame, incrementing `channel.message.dropped`
**Verified by:** `src/schemas/messages.test.ts::"accepts ask and ask-response variants"` AND `src/schemas/messages.test.ts::"rejects ask frames missing correlationId"`
---
### Requirement: OTel counters for ask hot paths
The package SHALL emit three additional instruments under meter `@suaveplan/broadcast-channel`: counter `channel.ask.sent` (attribute `key`), counter `channel.ask.responded` (attributes `key`, `outcome`), counter `channel.ask.timeout` (attribute `key`). `refreshBroadcastChannelMeters()` SHALL re-resolve all three alongside the existing six instruments.
#### Scenario: ask emits sent counter
- **WHEN** `ask("ping", payload)` is invoked on an open channel
- **THEN** `channel.ask.sent` has been incremented by 1 with attribute `{ key: "ping" }`
#### Scenario: ask-response emits responded counter
- **WHEN** the asker receives an `ask-response` with `outcome: "ok"` for a pending ask of key `"ping"`
- **THEN** `channel.ask.responded` has been incremented by 1 with attributes `{ key: "ping", outcome: "ok" }`
#### Scenario: timeout emits timeout counter
- **WHEN** an ask of key `"slow"` times out
- **THEN** `channel.ask.timeout` has been incremented by 1 with attribute `{ key: "slow" }`
**Verified by:** `src/otel/metrics.test.ts::"ask increments channel.ask.sent"` AND `src/otel/metrics.test.ts::"ask-response increments channel.ask.responded"` AND `src/otel/metrics.test.ts::"ask timeout increments channel.ask.timeout"`
---
### Requirement: Injectable `TimersLike` for deterministic timeouts
`TypedBroadcastChannelOptions` SHALL accept an optional `timers?: TimersLike` field matching `@suaveplan/types::TimersLike`. The default SHALL wrap the platform `setTimeout` / `clearTimeout` to satisfy the `(callback, delayMs) => number` / `(handle) => void` shape. All `ask()` timeouts SHALL be scheduled through the injected `timers.setTimer` and cleared through `timers.clearTimer`. Tests MUST be able to drive the entire ask lifecycle in zero wall-clock time by passing a fake `TimersLike`.
#### Scenario: fake TimersLike drives timeout deterministically
- **WHEN** the channel is constructed with a fake `TimersLike` whose `setTimer` records the callback without firing, the test fires the callback manually, and `ask("k", null, { timeoutMs: 5000 })` is awaited
- **THEN** the promise rejects with `BROADCAST_CHANNEL.ASK_TIMEOUT` and no real `setTimeout` was ever scheduled
**Verified by:** `src/ask/ask.test.ts::"fake TimersLike drives timeout deterministically"`
---
### Requirement: Extended `BROADCAST_CHANNEL_*` error registry
`BROADCAST_CHANNEL_CODES` SHALL contain three additional entries — `ASK_TIMEOUT`, `ASK_NO_RESPONDER`, `ASK_HANDLER_DUPLICATE` — bringing the total registry size to seven. Every new code SHALL be thrown by at least one production path (timeout in `AskController`, no-responder branch in `AskController`, second `onAsk` registration in `AskResponderRegistry`) and the registry SHALL remain frozen.
#### Scenario: every new code reachable
- **WHEN** the test suite exercises a timed-out ask, a `requireResponder: true` ask with no peer, and a duplicate `onAsk` registration
- **THEN** each of the three new codes is observed on a thrown error or a `Report.code` field, and `Object.isFrozen(BROADCAST_CHANNEL_CODES)` is `true`
**Verified by:** `src/errors/codes.test.ts::"every code is thrown by a production path"` AND `src/errors/codes.test.ts::"registry is frozen and shape matches"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,102 @@
# Tasks — add-broadcast-channel-ask
Sequential. No ticks may be authored retroactively.
## 1. Schema and types
- [ ] 1.1 Extend `src/schemas/messages.ts` — keep the existing `ChannelMessageSchema` shape via
`z.discriminatedUnion("type", ...)`-equivalent (or `z.union`) — adding two new variants
`AskFrameSchema` (with `correlationId`, `handlerKey`, `payload`, `expiresAt`) and
`AskResponseFrameSchema` (with `responseTo`, `handlerKey`, `outcome`, `payload`). Legacy
frames (`type` of `tab-join` / `tab-leave` / `heartbeat` / user-defined string + arbitrary
`payload`) remain accepted.
- [ ] 1.2 Add `AskFrame` / `AskResponseFrame` type exports to
`src/types/broadcast-channel.types.ts`. Extend `InternalMessageType` union by `"ask"` and
`"ask-response"`.
- [ ] 1.3 Update `src/schemas/messages.test.ts` — add scenarios: accepts well-formed `ask`,
accepts well-formed `ask-response`, rejects `ask` missing `correlationId`, rejects
`ask-response` missing `responseTo`.
## 2. Error codes
- [ ] 2.1 Extend `BROADCAST_CHANNEL_CODES` in `src/errors/codes.ts` by `ASK_TIMEOUT`,
`ASK_NO_RESPONDER`, `ASK_HANDLER_DUPLICATE`.
- [ ] 2.2 Add three error classes: `BroadcastChannelAskTimeoutError`,
`BroadcastChannelAskNoResponderError`, `BroadcastChannelAskHandlerDuplicateError`. Each
defaults its message and accepts `BaseOpts`.
- [ ] 2.3 Update `src/errors/codes.test.ts` — `every code is thrown by a production path` to
include the three new errors; registry-shape assertion grows to 7 codes.
## 3. OTel counters
- [ ] 3.1 In `src/otel/metrics.ts` declare module-level counters `askSent`, `askResponded`,
`askTimeout`; expose three recorder functions `recordAskSent(key, channelName)`,
`recordAskResponded(key, outcome, channelName)`, `recordAskTimeout(key, channelName)`.
Update `refreshBroadcastChannelMeters` to re-resolve them.
- [ ] 3.2 Update `src/otel/metrics.test.ts` — add three scenarios: `ask` sent increments
`channel.ask.sent`, response increments `channel.ask.responded` with `outcome` attribute,
timeout increments `channel.ask.timeout`.
## 4. `ask` feature module
- [ ] 4.1 `src/ask/ask.ts` — `AskController` class owning the pending-ask registry: keyed by
`correlationId`, each entry stores `{ key, resolve, reject, timerHandle, requireResponder,
respondersClaimed }`. Exposes `issue(handlerKey, payload, opts)` and `cancelAll(reason)`.
Uses injected `Clock`, `Rng`, `TimersLike`.
- [ ] 4.2 `src/ask/ask.md` — co-located doc ≥200 words covering Purpose / Features / Usage:Basic /
Usage:Advanced / API Reference / Implementation Notes.
- [ ] 4.3 `src/ask/responder.ts` — `AskResponderRegistry` mapping `handlerKey → handler`. Throws
`BroadcastChannelAskHandlerDuplicateError` on second registration. Returns a disposer.
- [ ] 4.4 `src/ask/responder.md` — co-located doc.
- [ ] 4.5 `src/ask/index.ts` — explicit named re-exports (no `export *`).
## 5. Wire into `TypedBroadcastChannel`
- [ ] 5.1 Add `timers?: TimersLike` to `TypedBroadcastChannelOptions`; default to
`{ setTimer: setTimeout, clearTimer: clearTimeout }` (wrapped to satisfy the signature).
- [ ] 5.2 Construct `AskController` and `AskResponderRegistry` in the constructor.
- [ ] 5.3 Implement `public ask<TReq, TResp>(handlerKey, payload, opts?): Promise<TResp>`. Calls
`recordAskSent`, schedules timeout via `TimersLike.setTimer`, posts `ask` frame through the
transport, returns the controller's promise.
- [ ] 5.4 Implement `public onAsk<TReq, TResp>(handlerKey, handler): () => void`. Throws
`ASK_HANDLER_DUPLICATE` on double-registration via `AskResponderRegistry`.
- [ ] 5.5 Extend `handleIncoming` — on `ask` frame: look up responder, if absent and
`requireResponder` would matter, increment `respondersClaimed` (we let the asker's timeout
decide). On `ask-response` frame: resolve the matching pending ask, call
`recordAskResponded(key, outcome, channelName)`.
- [ ] 5.6 On `close()`: call `AskController.cancelAll("channel-closed")` rejecting every pending
promise with `BroadcastChannelClosedError`.
## 6. Tests
- [ ] 6.1 `src/ask/ask.test.ts` — happy path with two channels sharing a fanout-fake transport;
correlation-ID isolation under interleaved asks; FakeClock + fake `TimersLike` drives
timeout → `ASK_TIMEOUT`; `requireResponder: true` with no peer answers → `ASK_NO_RESPONDER`;
handler-thrown error propagates as `outcome: "error"` and rejects the ask; close() rejects
pending asks with `CHANNEL_CLOSED`; default `opts.timeoutMs = 5000`.
- [ ] 6.2 `src/ask/responder.test.ts` — register / dispose / re-register; duplicate throws
`ASK_HANDLER_DUPLICATE`; non-matching `handlerKey` is dropped silently (no asker error, no
outbound frame); registered handler receives `(payload, ctx)` and may return a Promise.
## 7. Barrel and exports
- [ ] 7.1 Extend `src/index.ts` with named exports: `BroadcastChannelAskTimeoutError`,
`BroadcastChannelAskNoResponderError`, `BroadcastChannelAskHandlerDuplicateError`,
`recordAskSent`, `recordAskResponded`, `recordAskTimeout`, `AskFrameSchema`,
`AskResponseFrameSchema`, plus the two frame types.
- [ ] 7.2 Add `./ask/ask`, `./ask/responder` subpath exports in `package.json` for tree-shaking.
## 8. Pre-ship gates
- [ ] 8.1 `bunx biome check src/` — clean (zero diagnostics).
- [ ] 8.2 `bunx turbo lint typecheck test --filter=@suaveplan/broadcast-channel` — green.
- [ ] 8.3 `bunx turbo test:coverage --filter=@suaveplan/broadcast-channel` — 100/100/100/100.
- [ ] 8.4 `bun scripts/openspec-conformance.ts @suaveplan/broadcast-channel` — zero errors.
- [ ] 8.5 `bunx turbo build --filter=@suaveplan/broadcast-channel` — green.
## 9. Archive
- [ ] 9.1 Fold `specs/broadcast-channel/spec.md` (`## ADDED Requirements` block) into
`openspec/specs/broadcast-channel/spec.md`.
- [ ] 9.2 Move `openspec/changes/add-broadcast-channel-ask` →
`openspec/archive/2026-05-20-add-broadcast-channel-ask`.