Files
suaveplan/openspec/specs/api-ws/spec.md
2026-05-27 23:29:53 +01:00

203 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-ws (api-ws)
**Package**: `@suaveplan/api-ws`
**Status**: ACTIVE
> 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<WsConnection>()` 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<WsBroker>()` 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<WsEnvelope>` 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<CapabilityId>()` AND `expectTypeOf<"ws.broadcast-cross-tenant">().toMatchTypeOf<CapabilityId>()` 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"`