# Capability Spec Delta: api-ws (api-ws) **Package**: `@suaveplan/api-ws` **Status**: PROPOSED > Every requirement carries a `**Verified by:**` line. ## ADDED Requirements ### Requirement: WsConnection contract The package SHALL export `WsConnection` with: `id` (ULID), `principal: Principal`, `tenantId: TenantId`, `lastEventId: string | null`, `state: "open" | "closing" | "closed"`, methods `subscribe(topic, opts?)`, `unsubscribe(topic)`, `send(message)`, `close(code, reason?)`, `ack(id)`. The principal MUST be frozen at handshake for the connection's lifetime; token expiry mid-connection fires `connection.principal_expired` (the host emits this; the contract surfaces it). #### Scenario: every method present - **WHEN** `expectTypeOf()` is checked - **THEN** every documented method is declared with the documented signature **Verified by:** `src/connection/connection.spec-conformance.test.ts::"WsConnection declares every documented method"` --- ### Requirement: `import z from "zod"` everywhere Every Zod import MUST use the default form. #### Scenario: no named import of z - **WHEN** the test greps `import z` from `"zod"` - **THEN** zero matches **Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"` --- ### Requirement: ConnectionRegistry multi-index The package SHALL export `ConnectionRegistry` with O(1) lookup by `tenantId` and by `(topic, tenantId)`. `add`, `remove`, `lookupByTenant(tenant)`, `lookupByTopic(topic, tenant?)`, `countByTenant(tenant)`. `remove` MUST be idempotent (removing an absent connection is a no-op). #### Scenario: lookup by tenant returns matching set - **WHEN** 3 connections are added for tenant T1 and 2 for tenant T2 - **THEN** `lookupByTenant("T1").length === 3` AND `lookupByTenant("T2").length === 2` #### Scenario: remove is idempotent - **WHEN** `remove(conn)` is called twice - **THEN** the second call returns without throwing **Verified by:** `src/connection-registry/connection-registry.test.ts::"lookupByTenant returns matching set"` AND `src/connection-registry/connection-registry.test.ts::"remove is idempotent"` --- ### Requirement: SubscriptionBus broadcast with filter The package SHALL export `SubscriptionBus.broadcast(topic, { tenant, payload, filter? })` which delivers the payload to every connection registered against `(topic, tenant)` whose optional `filter(payload)` returns true. The payload MUST be encoded once and sent to each matching connection (no per-connection re-encoding). #### Scenario: broadcast reaches matching connections - **WHEN** two connections subscribe to `orders.created` for tenant T - **THEN** `broadcast("orders.created", { tenant: T, payload })` reaches both #### Scenario: filter excludes non-matching connections - **WHEN** filter returns `false` for one of two matching connections - **THEN** only the connection where filter returns true receives the payload **Verified by:** `src/subscription-bus/subscription-bus.test.ts::"broadcast reaches all matching connections"` AND `src/subscription-bus/subscription-bus.test.ts::"filter predicate excludes non-matching connections"` --- ### Requirement: Cross-tenant broadcast requires explicit capability `SubscriptionBus.broadcast(topic, { tenant, payload })` where `tenant` differs from the caller's `principal.tenantId` (or `tenant` is the wildcard "*") MUST throw `WS.CROSS_TENANT_BROADCAST_DENIED` unless the caller's `Caps` includes `"ws.broadcast-cross-tenant"`. Default is deny. #### Scenario: same-tenant broadcast succeeds without capability - **WHEN** caller's tenant is T AND broadcast is for tenant T - **THEN** broadcast succeeds #### Scenario: cross-tenant denied by default - **WHEN** caller's tenant is T AND broadcast is for tenant T2 AND `Caps` does NOT include `ws.broadcast-cross-tenant` - **THEN** broadcast throws `WS.CROSS_TENANT_BROADCAST_DENIED` **Verified by:** `src/subscription-bus/cross-tenant.test.ts::"same-tenant broadcast succeeds without capability"` AND `src/subscription-bus/cross-tenant.test.ts::"cross-tenant denied without explicit capability"` --- ### Requirement: WsBroker contract The package SHALL export `WsBroker` for multi-host fan-out: `publish(topic, envelope)`, `subscribe(topic, handler)`, `dispose()`. The broker MUST NOT migrate connections — it only makes broadcasts visible across host replicas. `dispose()` MUST close every subscription cleanly. #### Scenario: every method present - **WHEN** `expectTypeOf()` is checked - **THEN** all three methods are declared **Verified by:** `src/broker/broker.spec-conformance.test.ts::"WsBroker declares publish subscribe dispose"` --- ### Requirement: Envelope Zod schema The package SHALL export `wsEnvelopeSchema: z.ZodSchema` describing the wire envelope: `{ id: string, type: string, topic?: string, payload?: unknown, ts?: string }`. Unknown top-level keys MUST be rejected. #### Scenario: minimal envelope parses - **WHEN** `{ id: "x", type: "msg" }` is parsed - **THEN** it succeeds #### Scenario: extra key rejected - **WHEN** `{ id: "x", type: "msg", extra: 1 }` is parsed - **THEN** Zod reports an issue at `extra` **Verified by:** `src/envelope/envelope.test.ts::"minimal envelope parses"` AND `src/envelope/envelope.test.ts::"unknown keys rejected"` --- ### Requirement: Backpressure policy The package SHALL export `BackpressurePolicy` with default `maxQueuedMessages = 256`. When a connection's send queue exceeds the cap, the connection MUST receive a `BACKPRESSURE_EVICTED` close frame and be dropped from the registry. #### Scenario: queue saturation triggers eviction - **WHEN** 257 messages are queued for a connection with default policy - **THEN** the eviction handler fires AND `WS.BACKPRESSURE_EVICTED` is the close code **Verified by:** `src/backpressure/backpressure.test.ts::"queue saturation triggers BACKPRESSURE_EVICTED"` --- ### Requirement: Heartbeat policy The package SHALL export `HeartbeatPolicy` with default `heartbeatMs = 30000`. Two missed pongs MUST trigger `onClose` with code `ABNORMAL`. #### Scenario: two missed pongs trigger close - **WHEN** a fake clock advances past 2 × heartbeatMs without pong - **THEN** the close handler fires with `code === "ABNORMAL"` AND `WS.HEARTBEAT_FAILED` is emitted **Verified by:** `src/heartbeat/heartbeat.test.ts::"two missed pongs trigger ABNORMAL close"` --- ### Requirement: Reconnect-token shape The package SHALL export `ReconnectToken` HMAC envelope carrying `{ tenant, principalId, lastEventId, exp }` with default 5-minute TTL. The HMAC verifier MUST reject expired tokens (`WS.RECONNECT_TOKEN_INVALID`) and tokens with bad MAC. #### Scenario: valid token verifies - **WHEN** a token signed within TTL is verified - **THEN** the verifier returns the parsed payload #### Scenario: expired token rejected - **WHEN** the fake clock advances past TTL - **THEN** the verifier throws `WS.RECONNECT_TOKEN_INVALID` **Verified by:** `src/reconnect-token/reconnect-token.test.ts::"valid token verifies"` AND `src/reconnect-token/reconnect-token.test.ts::"expired token rejected"` --- ### Requirement: CapabilityId augmentation contributes "ws" and "ws.broadcast-cross-tenant" The package SHALL augment the `CapabilityId` union to include both ids. #### Scenario: both ids assignable to CapabilityId - **WHEN** `expectTypeOf<"ws">().toMatchTypeOf()` AND `expectTypeOf<"ws.broadcast-cross-tenant">().toMatchTypeOf()` are checked - **THEN** both pass **Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"ws and ws.broadcast-cross-tenant are valid CapabilityIds"` --- ### Requirement: Error codes registry frozen The package SHALL export `WS_CODES` containing exactly: `HANDSHAKE_FAILED`, `PRINCIPAL_EXPIRED`, `BACKPRESSURE_EVICTED`, `HEARTBEAT_FAILED`, `CONNECTION_CAP_EXCEEDED`, `CROSS_TENANT_BROADCAST_DENIED`, `RECONNECT_TOKEN_INVALID`, `BROKER_UNAVAILABLE`. #### Scenario: every code reachable - **WHEN** the test enumerates `WS_CODES` - **THEN** every code has a production throw site **Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every WS_CODES entry thrown by production path"` --- ### Requirement: Conformance harness for engines The package SHALL export `runWsEngineConformance({ factory, label })`. Every concrete engine + broker pair MUST pass. #### Scenario: harness covers handshake, subscribe, broadcast, backpressure, heartbeat, reconnect - **WHEN** the harness is run against a stub engine - **THEN** test categories exist for every documented behavior **Verified by:** `src/testing/conformance.test.ts::"harness covers handshake subscribe broadcast backpressure heartbeat reconnect"`