# 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(handlerKey: string, payload: TReq, opts?: AskOptions): Promise`. 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(handlerKey: string, handler: (payload: TReq, ctx: AskHandlerContext) => TResp | Promise): () => 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"`