Files
suaveplan/openspec/archive/2026-05-20-add-broadcast-channel-ask/specs/broadcast-channel/spec.md
2026-05-27 23:29:53 +01:00

11 KiB

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"