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

103 lines
5.9 KiB
Markdown

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