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

5.9 KiB

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 TypedBroadcastChannels 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.