# TCP Wire Format Specification

## Overview

This document defines the binary wire format used for TCP communication between clients and the rust-fafb-q server. It is the authoritative specification — the codec implementation must match this document exactly.

For the HTTP API (publish and query only), see [api.md](api.md).

All multi-byte integers are **big-endian** (network byte order). All strings are **UTF-8** encoded and length-prefixed with a 2-byte (u16) length. All payloads are length-prefixed with a 4-byte (u32) length.

## Protocol Version

The current protocol version is **1**. The version is transmitted once during the Connect handshake (see below). If the server does not support the client's requested version, it responds with an Error frame (code `0x0007`) and closes the connection.

## Frame Envelope

Every message on the wire is wrapped in a length-prefixed frame:

```
+---------------------+-------------------+-----------------+
| Frame Length (4B)    | Message Type (1B) | Payload (N B)   |
+---------------------+-------------------+-----------------+
```

| Field | Type | Description |
|---|---|---|
| Frame Length | u32 | Total number of bytes that follow (message type + payload). Does **not** include the 4-byte length field itself. |
| Message Type | u8 | Identifies the message kind (see table below). |
| Payload | variable | Message-specific fields, laid out as specified per message type. |

### Max Frame Size

The server enforces a maximum frame size (configurable via `tcp.max_message_size`, default 1,048,576 bytes / 1 MB). If the length prefix exceeds this limit, the server closes the connection with an Error frame (code `0x0006`) before reading the oversized payload.

A frame length of 0 is invalid (minimum is 1 byte for the message type).

## String Encoding

Strings appear throughout the protocol. They are always encoded as:

```
+--------------------+------------------+
| String Length (2B) | UTF-8 Data (N B) |
+--------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| String Length | u16 | Byte length of the UTF-8 data that follows. |
| UTF-8 Data | bytes | The string content. |

A string length of 0 represents an empty string (used as "absent" for optional string fields).

## Message Types

| Byte | Name | Direction | Description |
|---|---|---|---|
| `0x01` | Connect | client → server | Authentication handshake |
| `0x02` | ConnectAck | server → client | Handshake response |
| `0x03` | Subscribe | client → server | Subscribe to a topic |
| `0x04` | SubscribeAck | server → client | Subscription confirmed |
| `0x05` | Unsubscribe | client → server | Unsubscribe from a topic |
| `0x06` | UnsubscribeAck | server → client | Unsubscription confirmed |
| `0x07` | Publish | client → server | Publish a message to a topic |
| `0x08` | PublishAck | server → client | Publish confirmed with assigned message ID |
| `0x09` | Deliver | server → client | Deliver a message to a subscriber/consumer |
| `0x0A` | Ack | client → server | Acknowledge receipt of a delivered message |
| `0x0B` | Error | server → client | Error notification |
| `0x0C` | Ping | either → either | Keepalive request |
| `0x0D` | Pong | either → either | Keepalive response |
| `0x0E` | SubscribeAt | client → server | Subscribe starting from a specific offset |
| `0x0F` | SubscribeFrom | client → server | Subscribe starting from a timestamp |
| `0x10` | GoingAway | server → client | Planned shutdown notification with deadline |
| `0x11` | Reconnect | client → server | Batch re-auth and re-subscribe after deploy |
| `0x12` | ReconnectAck | server → client | Reconnect result with per-subscription status |
| `0x13` | Fetch | client → server | Pull messages starting from an offset |
| `0x14` | FetchResponse | server → client | Batch of fetched messages with offset metadata |
| `0x15` | FetchFrom | client → server | Pull messages starting from a timestamp |
| `0x16` | Nack | client → server | Explicitly reject a delivered message (requires `CAP_NACK`) |
| `0x17` | NackAck | server → client | Result of a Nack (dead-lettered, dropped, or failed) |

The server must reject any frame with an unknown message type byte by sending an Error frame (code `0x0005`) and closing the connection.

The server must reject frames sent in the wrong direction (e.g., a client sending ConnectAck) with Error code `0x0005`.

## Message Field Layouts

### 0x01 — Connect

Sent by the client immediately after TCP connection is established. This is the authentication handshake. No other messages may be sent before a successful Connect/ConnectAck exchange.

```
+------------------+-------------------+------------------+------------------------+
| Version (1B)     | Namespace (str)   | API Key (str)    | Capabilities (4B) OPT  |
+------------------+-------------------+------------------+------------------------+
```

| Field | Type | Description |
|---|---|---|
| Version | u8 | Protocol version the client speaks (must be `1`). |
| Namespace | string | The namespace to authenticate against. |
| API Key | string | The API key for the namespace. |
| Capabilities | u32 | **Optional.** Bitmask of capabilities the client supports (see [Capability Negotiation](#capability-negotiation)). If the frame has no remaining bytes after API Key, capabilities default to `0` (legacy v1 client). |

**Backward compatibility:** The decoder uses the frame length to determine whether the capabilities field is present. After parsing Version + Namespace + API Key, if 4 or more bytes remain in the frame, they are parsed as the capabilities `u32`. If 0 bytes remain, the client is a legacy v1 client with capabilities = 0. This ensures existing v1 clients connect without modification.

### 0x02 — ConnectAck

Sent by the server in response to Connect.

```
+----------------+-------------------+-------------------------------+
| Status (1B)    | Message (str)     | Server Capabilities (4B) OPT  |
+----------------+-------------------+-------------------------------+
```

| Field | Type | Description |
|---|---|---|
| Status | u8 | `0x00` = success, `0x01` = failure. |
| Message | string | Human-readable status message. Empty on success. |
| Server Capabilities | u32 | **Optional.** Bitmask of capabilities the server agrees to use for this session (intersection of client and server capabilities). Present only when the client sent capabilities. |

On failure, the server sends ConnectAck and then closes the connection.

### Capability Negotiation

Capabilities allow the server and client to negotiate optional protocol features without breaking backward compatibility. The negotiation occurs during the Connect/ConnectAck handshake:

1. Client sends `Connect` with `capabilities` bitmask advertising supported features
2. Server computes agreed capabilities: `agreed = client_caps & SERVER_CAPABILITIES`
3. Server sends `ConnectAck` with `server_capabilities = agreed`
4. Both sides use only the agreed capabilities for this session
5. If a client omits the capabilities field (legacy v1), `agreed = 0` — no new features, no regression

#### Capability Bits

| Bit | Constant | Description |
|---|---|---|
| 0 | `CAP_GOING_AWAY` | Client/server understands the GoingAway frame (0x10). Without this, the server sends `Error { ServerDraining }` on drain instead. |
| 1 | `CAP_RECONNECT` | Client/server understands Reconnect (0x11) and ReconnectAck (0x12) frames. |
| 2 | `CAP_NACK` | Client/server understands Nack (0x16) and NackAck (0x17) frames for explicit message rejection. Without this, sending Nack returns `Error { InvalidFrame }`. |
| 3 | `CAP_HEADERS` | Client/server supports message headers on Publish (0x07), Deliver (0x09), and FetchResponse (0x14) frames. When negotiated, these frames include a header section. When not negotiated, wire format is identical to the legacy headerless format. |
| 4 | `CAP_WILDCARD` | Client/server supports wildcard topic subscriptions. When negotiated, the `topic` field in Subscribe/SubscribeAt/SubscribeFrom may contain wildcard patterns (`*` for one segment, `>` for trailing segments). `SubscribeAck` includes a `matched_count` field. Without this capability, wildcard patterns in subscribe frames are rejected with `ErrorCode::InvalidTopic`. |
| 5 | `CAP_BATCH_ACK` | Client/server supports BatchAck (0x18) and BatchAckResponse (0x19) frames for acknowledging multiple messages in a single round-trip. |
| 6 | `CAP_MESSAGE_TTL` | Client/server supports per-message TTL on Publish (0x07), Deliver (0x09), and FetchResponse (0x14) frames. When negotiated, these frames include a `ttl_ms` field. When not negotiated, the TTL field is omitted from the wire format and defaults to 0 (no expiry). |
| 7–31 | *(reserved)* | Reserved for future capabilities. Must be sent as 0. |

The server's `SERVER_CAPABILITIES` constant is `CAP_GOING_AWAY | CAP_RECONNECT | CAP_NACK | CAP_HEADERS | CAP_WILDCARD | CAP_BATCH_ACK | CAP_MESSAGE_TTL` (`0x0000007F`).

**Per-session tracking:** The agreed capabilities are stored in the session's authenticated state and referenced when the server needs to decide which frames to send (e.g., GoingAway vs. Error on drain).

### 0x03 — Subscribe

Subscribe to a topic. May only be sent after a successful Connect. Requires `Subscribe` permission.

```
+------------------+------------------+
| Topic (str)      | Group (str)      |
+------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic name to subscribe to. Must be non-empty. When `CAP_WILDCARD` is negotiated, the topic may contain wildcard patterns: `*` (matches one dot-delimited segment) or `>` (matches one or more trailing segments). See [Wildcard Subscriptions](#wildcard-subscriptions). |
| Group | string | Consumer group name. Empty string (length 0) means individual subscriber (fan-out/broadcast mode). Non-empty means join or create the named consumer group (competing-consumers mode). Consumer groups are supported with wildcard patterns — per-topic groups are created for each matching topic. |

### 0x04 — SubscribeAck

Sent by the server to confirm a successful subscription (for Subscribe, SubscribeAt, or SubscribeFrom).

```
+------------------+-----------------------+------------------+---------------------------+
| Topic (str)      | Start Offset (8B)     | Group (str)      | Matched Count (2B) OPT    |
+------------------+-----------------------+------------------+---------------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic that was subscribed to. For wildcard subscriptions, this echoes the pattern (e.g., `orders.*`), not an expanded topic. |
| Start Offset | u64 | The actual starting offset for this subscription. For wildcard subscriptions, this is 0 (no single offset). For `Subscribe`, this is the current head offset. For `SubscribeAt`/`SubscribeFrom`, this may differ from the requested offset if it was clamped (past head or below oldest available). |
| Group | string | The consumer group name, or empty if individual subscriber. |
| Matched Count | u16 | Number of existing topics currently matching the subscription. Always encoded by the current server. For exact-match subscriptions, this is 0. For wildcard subscriptions, this is the count of topics matching the pattern at subscribe time (new topics may match later via auto-subscribe). **Backward compatible:** decoders should check for 2+ remaining bytes after the Group field — if present, read as `u16`; if absent (older server), default to 0. Old decoders that stop reading after the Group field safely ignore the trailing bytes. |

### 0x05 — Unsubscribe

Unsubscribe from a topic or wildcard pattern. The group field must match the original subscription.

```
+------------------+------------------+
| Topic (str)      | Group (str)      |
+------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic or wildcard pattern to unsubscribe from. Must match the original subscription exactly. Unsubscribing a specific topic within a wildcard (e.g., sending `Unsubscribe { topic: "orders.us" }` while subscribed to `orders.*`) returns `ErrorCode::InvalidOperation`. |
| Group | string | Consumer group name, or empty if unsubscribing an individual subscription. |

### 0x06 — UnsubscribeAck

Sent by the server to confirm a successful unsubscription.

```
+------------------+------------------+
| Topic (str)      | Group (str)      |
+------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic that was unsubscribed from. |
| Group | string | The consumer group name, or empty if individual. |

### 0x07 — Publish

Publish a message to a topic. Requires `Publish` permission. The server assigns a `MessageId`; the client does not provide one.

**Base format (no optional capabilities):**

```
+------------------+------------------------+-----------------+
| Topic (str)      | Payload Length (4B)     | Payload (N B)   |
+------------------+------------------------+-----------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic to publish to. Must be non-empty. |
| Payload Length | u32 | Byte length of the payload. |
| Payload | bytes | Opaque message payload. May be empty (length 0). |

**With `CAP_MESSAGE_TTL` negotiated:**

The `ttl_ms` field is inserted after Topic, before headers/payload:

```
+------------------+-----------------+------------------------+-----------------+
| Topic (str)      | TTL ms (4B)     | Payload Length (4B)     | Payload (N B)   |
+------------------+-----------------+------------------------+-----------------+
```

| Field | Type | Description |
|---|---|---|
| TTL ms | u32 | Time-to-live in milliseconds. 0 = no expiry (never expires). Subject to namespace `min_ttl_secs`/`max_ttl_secs` limits — values below minimum are rejected, values above maximum are clamped. If the namespace has `default_ttl_secs` configured and the client sends 0, the server applies the default. |

**With `CAP_HEADERS` negotiated:**

```
+------------------+---------------------+------------------+------------------------+-----------------+
| Topic (str)      | Header Count (2B)   | Headers (var)    | Payload Length (4B)     | Payload (N B)   |
+------------------+---------------------+------------------+------------------------+-----------------+
```

**With both `CAP_MESSAGE_TTL` and `CAP_HEADERS`:**

```
+------------------+-----------------+---------------------+------------------+------------------------+-----------------+
| Topic (str)      | TTL ms (4B)     | Header Count (2B)   | Headers (var)    | Payload Length (4B)     | Payload (N B)   |
+------------------+-----------------+---------------------+------------------+------------------------+-----------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic to publish to. Must be non-empty. |
| TTL ms | u32 | *(Only with `CAP_MESSAGE_TTL`)* Time-to-live in milliseconds. 0 = no expiry. |
| Header Count | u16 | *(Only with `CAP_HEADERS`)* Number of header key-value pairs that follow. May be 0. |
| Headers | repeated | *(Only with `CAP_HEADERS`)* `header_count` × header entry (see [Header Encoding](#header-encoding)). |
| Payload Length | u32 | Byte length of the payload. |
| Payload | bytes | Opaque message payload. May be empty (length 0). |

The format selection is a per-connection decision based on the negotiated capabilities — there is no per-frame ambiguity. See [Header Encoding](#header-encoding) for header entry layout and validation limits.

Note: The payload uses a 4-byte (u32) length prefix rather than the standard 2-byte string prefix, to support payloads up to the max frame size.

### 0x08 — PublishAck

Sent by the server to confirm a publish was accepted and persisted.

```
+------------------------+
| Message ID (8B)        |
+------------------------+
```

| Field | Type | Description |
|---|---|---|
| Message ID | u64 | Server-assigned unique message identifier. |

### 0x09 — Deliver

Sent by the server to deliver a message to a subscriber or consumer group member.

**Base format (no optional capabilities):**

```
+------------------------+------------------+------------------------+------------------+------------------------+-----------------+
| Message ID (8B)        | Offset (8B)      | Timestamp (8B)         | Topic (str)      | Payload Length (4B)     | Payload (N B)   |
+------------------------+------------------+------------------------+------------------+------------------------+-----------------+
```

| Field | Type | Description |
|---|---|---|
| Message ID | u64 | The message's unique identifier (used for Ack or Nack). |
| Offset | u64 | The message's position within the topic log. |
| Timestamp | u64 | Publish timestamp — milliseconds since Unix epoch. |
| Topic | string | The topic this message was published to. |
| Payload Length | u32 | Byte length of the payload. |
| Payload | bytes | The message payload. |

**With `CAP_MESSAGE_TTL` negotiated:**

The `ttl_ms` field is inserted after Timestamp, before Topic:

```
+------------------------+------------------+------------------------+-----------------+------------------+------------------------+-----------------+
| Message ID (8B)        | Offset (8B)      | Timestamp (8B)         | TTL ms (4B)     | Topic (str)      | Payload Length (4B)     | Payload (N B)   |
+------------------------+------------------+------------------------+-----------------+------------------+------------------------+-----------------+
```

| Field | Type | Description |
|---|---|---|
| TTL ms | u32 | Original TTL in milliseconds. 0 = no expiry. Client can compute remaining TTL: `(timestamp + ttl_ms) - now_ms`. |

**With `CAP_HEADERS` negotiated:**

The header section is inserted before payload:

```
+------------------------+------------------+------------------------+------------------+---------------------+------------------+------------------------+-----------------+
| Message ID (8B)        | Offset (8B)      | Timestamp (8B)         | Topic (str)      | Header Count (2B)   | Headers (var)    | Payload Length (4B)     | Payload (N B)   |
+------------------------+------------------+------------------------+------------------+---------------------+------------------+------------------------+-----------------+
```

**With both `CAP_MESSAGE_TTL` and `CAP_HEADERS`:**

```
+------------------------+------------------+------------------------+-----------------+------------------+---------------------+------------------+------------------------+-----------------+
| Message ID (8B)        | Offset (8B)      | Timestamp (8B)         | TTL ms (4B)     | Topic (str)      | Header Count (2B)   | Headers (var)    | Payload Length (4B)     | Payload (N B)   |
+------------------------+------------------+------------------------+-----------------+------------------+---------------------+------------------+------------------------+-----------------+
```

| Field | Type | Description |
|---|---|---|
| Message ID | u64 | The message's unique identifier (used for Ack or Nack). |
| Offset | u64 | The message's position within the topic log. |
| Timestamp | u64 | Publish timestamp — milliseconds since Unix epoch. |
| TTL ms | u32 | *(Only with `CAP_MESSAGE_TTL`)* Original TTL in milliseconds. 0 = no expiry. |
| Topic | string | The topic this message was published to. |
| Header Count | u16 | *(Only with `CAP_HEADERS`)* Number of header key-value pairs that follow. May be 0. |
| Headers | repeated | *(Only with `CAP_HEADERS`)* `header_count` × header entry (see [Header Encoding](#header-encoding)). |
| Payload Length | u32 | Byte length of the payload. |
| Payload | bytes | The message payload. |

Headers from the original publish are forwarded exactly as stored. Messages published without headers (or by legacy clients) are delivered with `header_count = 0`.

### 0x0A — Ack

Sent by the client to acknowledge receipt and processing of a delivered message.

```
+------------------------+
| Message ID (8B)        |
+------------------------+
```

| Field | Type | Description |
|---|---|---|
| Message ID | u64 | The message ID being acknowledged (from Deliver). |

### 0x0B — Error

Sent by the server when an error occurs. May be sent at any point after connection.

```
+--------------------+-------------------+
| Error Code (2B)    | Message (str)     |
+--------------------+-------------------+
```

| Field | Type | Description |
|---|---|---|
| Error Code | u16 | Numeric error code (see table below). |
| Message | string | Human-readable error description. |

### 0x0C — Ping

Keepalive probe. Either side may send. The receiver must respond with Pong.

```
(empty payload — no fields)
```

### 0x0D — Pong

Keepalive response. Sent in reply to a Ping.

```
(empty payload — no fields)
```

### 0x0E — SubscribeAt

Subscribe starting from a specific offset. Requires Subscribe permission. The server responds with a SubscribeAck whose `start_offset` field reflects the actual starting position (which may be clamped if the requested offset is past the head or below the oldest retained data).

**Wildcard restriction:** `SubscribeAt` with a wildcard pattern (topic containing `*` or `>`) is rejected with `ErrorCode::InvalidOperation`. Offsets are per-topic and meaningless across multiple topics. Use `Subscribe` (head) or `SubscribeFrom` (timestamp) instead.

```
+------------------+------------------+------------------+
| Topic (str)      | Offset (8B)      | Group (str)      |
+------------------+------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic to subscribe to. Must not contain wildcard characters. |
| Offset | u64 | The offset to start consuming from. |
| Group | string | Consumer group name, or empty for individual subscription. |

### 0x0F — SubscribeFrom

Subscribe starting from the first message at or after a timestamp. Requires Subscribe permission. The server resolves the timestamp to an offset using the segment index, then behaves like SubscribeAt. If no message exists at or after the timestamp, the subscription starts at the current head (live tail).

**Wildcard support:** When the topic is a wildcard pattern and `CAP_WILDCARD` is negotiated, the server resolves the timestamp independently for each matching topic. Each topic starts delivery from its own resolved offset.

```
+------------------+-----------------------+------------------+
| Topic (str)      | Timestamp (8B)        | Group (str)      |
+------------------+-----------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic to subscribe to. May contain wildcard patterns when `CAP_WILDCARD` is negotiated. |
| Timestamp | u64 | Milliseconds since Unix epoch. The subscription starts from the first message with `timestamp >= this value`. For wildcard subscriptions, resolved independently per matching topic. |
| Group | string | Consumer group name, or empty for individual subscription. |

### 0x10 — GoingAway

Sent by the server to notify capability-aware clients of a planned shutdown. This is NOT an Error frame — it gives the client a grace period to finish in-flight work before the connection is closed.

Only sent to clients that negotiated `CAP_GOING_AWAY`. Clients without this capability receive `Error { ServerDraining }` instead.

```
+------------------+-----------------------+-------------------+
| Reason (1B)      | Deadline (4B)         | Message (str)     |
+------------------+-----------------------+-------------------+
```

| Field | Type | Description |
|---|---|---|
| Reason | u8 | Why the server is going away (see table below). Unknown values must be accepted — do not reject. |
| Deadline | u32 | Milliseconds until the server will force-close the connection. This is a hint, not a contract — the server may close earlier. |
| Message | string | Human-readable message (e.g., "server upgrading to v2.1"). |

**Reason codes:**

| Value | Constant | Description |
|---|---|---|
| `0x01` | `UPGRADE` | Server is being upgraded to a new version |
| `0x02` | `MAINTENANCE` | Server is being taken down for maintenance |
| `0x03` | `OVERLOADED` | Server is shedding connections (reserved for future load-shedding) |
| `0x04` | `SHUTDOWN` | Server is shutting down permanently |

Reason codes are a newtype `u8`, not an enum. Unknown reason values (e.g., `0x05` from a future server) must be accepted and logged, never rejected. Clients behave identically regardless of reason (ack in-flight, disconnect, reconnect with jitter) — the reason is informational for logging and dashboards.

**Client behavior on receiving GoingAway:**

1. Stop sending new Subscribe/SubscribeAt/SubscribeFrom requests
2. Ack all in-flight delivered messages
3. Optionally unsubscribe from active topics
4. Disconnect gracefully
5. Wait `deadline_ms + jitter` then reconnect (see [Reconnect backoff](#reconnect-backoff))

**Server behavior during drain:**

After sending GoingAway, the session transitions to a `Draining` state:
- `Publish` — allowed (in-flight work must complete)
- `Subscribe` / `SubscribeAt` / `SubscribeFrom` — rejected with `Error { ServerDraining }`
- `Ack` — allowed (clients should ack in-flight messages)
- `Nack` — allowed (resolves inflight state, helps drain complete)
- `Unsubscribe` — allowed (clients should clean up)
- `Fetch` / `FetchFrom` — allowed (stateless, no server state created)
- `Ping` / `Pong` — allowed (keepalive continues during drain)
- Delivery tasks continue running — messages already queued are still delivered

### 0x11 — Reconnect

Sent by the client to restore authentication and subscriptions in a single round-trip after a deploy. Requires `CAP_RECONNECT` capability. Must be sent in the pre-auth state — sending Reconnect after authentication returns `Error { InvalidFrame }`.

This is an **optimization** — `Connect` + multiple `SubscribeAt` always works and is the recommended default path. Reconnect reduces `1 + 2N` round-trips to 2 for clients with N subscriptions.

```
+------------------+-------------------+------------------+------------------------+---------------------------+----------------------+
| Version (1B)     | Namespace (str)   | API Key (str)    | Capabilities (4B)      | Subscription Count (2B)   | Subscriptions (var)  |
+------------------+-------------------+------------------+------------------------+---------------------------+----------------------+
```

| Field | Type | Description |
|---|---|---|
| Version | u8 | Protocol version (must be `1`). |
| Namespace | string | Namespace to authenticate against. |
| API Key | string | API key for the namespace. |
| Capabilities | u32 | Client capabilities bitmask (same as Connect). |
| Subscription Count | u16 | Number of subscription entries that follow. Maximum 10,000. |
| Subscriptions | repeated | `subscription_count` × subscription entry (see below). |

**Subscription entry:**

```
+------------------+------------------+----------------------------+
| Topic (str)      | Group (str)      | Last Acked Offset (8B)     |
+------------------+------------------+----------------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | Topic name to re-subscribe to. |
| Group | string | Consumer group name, or empty for individual subscriber. |
| Last Acked Offset | u64 | The last offset the client successfully acknowledged. The server resumes delivery from `last_acked_offset + 1`. |

### 0x12 — ReconnectAck

Sent by the server in response to Reconnect. Contains the overall reconnect status and per-subscription results.

```
+------------------+-------------------+-------------------------------+-----------------------+---------------------+
| Status (1B)      | Message (str)     | Server Capabilities (4B)      | Result Count (2B)     | Results (var)       |
+------------------+-------------------+-------------------------------+-----------------------+---------------------+
```

| Field | Type | Description |
|---|---|---|
| Status | u8 | Overall reconnect status (see table below). |
| Message | string | Human-readable status message. |
| Server Capabilities | u32 | Negotiated capabilities (same as ConnectAck). |
| Result Count | u16 | Number of per-subscription result entries. |
| Results | repeated | `result_count` × subscription result (see below). |

**Status codes:**

| Value | Name | Description |
|---|---|---|
| `0x00` | `Success` | All subscriptions restored. `OffsetClamped` counts as success (it's a warning). |
| `0x01` | `Partial` | Some subscriptions had hard failures. **Atomic rollback**: all successful subscriptions are unsubscribed — no active subscriptions exist. The connection IS established — client should use `Connect + SubscribeAt` for individual control. |
| `0x02` | `AuthFailed` | API key invalid, namespace mismatch, or connection rejected. Connection closed. |

**Subscription result entry:**

```
+------------------+------------------+-----------------------+----------------------------+
| Topic (str)      | Group (str)      | Sub Status (1B)       | Actual Start Offset (8B)   |
+------------------+------------------+-----------------------+----------------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | The topic from the request. |
| Group | string | The group from the request. |
| Sub Status | u8 | Per-subscription status (see table below). |
| Actual Start Offset | u64 | Where delivery will actually start. |

**Subscription status codes:**

| Value | Name | Description |
|---|---|---|
| `0x00` | `Restored` | Subscription restored at the requested offset. |
| `0x01` | `OffsetClamped` | Requested offset was below the oldest retained data. Delivery starts from the oldest available. This is a **warning**, not a failure — does not trigger rollback. |
| `0x02` | `TopicNotFound` | Topic does not exist. **Hard failure** — triggers rollback. |
| `0x03` | `PermissionDenied` | Client lacks Subscribe permission. **Hard failure** — triggers rollback. |
| `0x04` | `GroupFull` | Consumer group is at capacity. **Hard failure** — triggers rollback. |

**Atomic batch subscribe:** All subscriptions are committed (forwarders spawned) BEFORE the ReconnectAck is sent and BEFORE any Deliver frames. In the rollback case (Partial status), all successful subscriptions are unsubscribed — the client ends up with zero active subscriptions and must fall back to `Connect + SubscribeAt`.

### Reconnect Backoff

After receiving GoingAway or disconnecting during a deploy, clients should reconnect with jitter to avoid thundering herd:

```
reconnect_delay = deadline_ms + uniform_random(0, min(5000, deadline_ms / 2))
```

On connection failure after the initial attempt, use exponential backoff:

```
retry_delay = min(base_delay * 2^attempt, 30000)   // cap at 30 seconds
```

The jitter formula scales with drain timeout — short drains get tight jitter, long drains get wide jitter. This prevents connection spikes on the new server that may exceed `max_connections`.

### 0x13 — Fetch

Stateless pull-based message retrieval. The client requests a batch of messages starting from a specific offset. The server responds with a FetchResponse (0x14). Does not create subscriptions, delivery tasks, or inflight tracking.

Requires `Fetch` permission (independent of `Subscribe`).

**Wildcard restriction:** Fetch and FetchFrom do not support wildcard patterns. If the topic contains `*` or `>`, the server returns `ErrorCode::InvalidTopic` with message "wildcard patterns not supported for Fetch". Fetch returns messages from a single topic with contiguous offsets — cross-topic merging is not supported. Use Subscribe for wildcard consumption, or fetch each topic individually.

```
+------------------+------------------+---------------------+
| Topic (str)      | Offset (8B)      | Max Count (4B)      |
+------------------+------------------+---------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | Topic to fetch from. Must be non-empty. |
| Offset | u64 | Starting offset (inclusive). If below the oldest retained offset, the server clamps and sends an `OffsetNotAvailable` warning before the `FetchResponse`. |
| Max Count | u32 | Maximum messages to return. Must be > 0 (server returns `InvalidFrame` if zero). Server may return fewer due to frame size limits or end of topic. |

Fetch is allowed during drain (stateless, no server state created). Fetch requests on a single TCP connection are processed sequentially (FIFO). For parallel fetches, use multiple connections or the HTTP endpoint.

### 0x14 — FetchResponse

Sent by the server in response to both Fetch (0x13) and FetchFrom (0x15).

```
+------------------+---------------------+---------------------+-------------------+------------------+
| Topic (str)      | Next Offset (8B)    | Head Offset (8B)    | Count (4B)        | Messages (var)   |
+------------------+---------------------+---------------------+-------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | Echoes the requested topic. |
| Next Offset | u64 | Offset to use in the next Fetch call. If `count > 0`: last returned offset + 1. If `count == 0`: same as Head Offset. |
| Head Offset | u64 | Topic's current head offset (next offset to be assigned). Use `next_offset < head_offset` to check if more messages are available. |
| Count | u32 | Number of message entries that follow. |
| Messages | repeated | `count` × message entry (see below). |

**Message entry (base format):**

```
+------------------+---------------------------+------------------------+------------------+
| Offset (8B)      | Timestamp Millis (8B)     | Payload Length (4B)    | Payload (var)    |
+------------------+---------------------------+------------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Offset | u64 | Message offset within the topic. |
| Timestamp Millis | u64 | Publish timestamp (milliseconds since Unix epoch). |
| Payload Length | u32 | Length of the payload in bytes. |
| Payload | bytes | Raw message payload. |

**Message entry (with `CAP_MESSAGE_TTL`):**

```
+------------------+---------------------------+-----------------+------------------------+------------------+
| Offset (8B)      | Timestamp Millis (8B)     | TTL ms (4B)     | Payload Length (4B)    | Payload (var)    |
+------------------+---------------------------+-----------------+------------------------+------------------+
```

**Message entry (with `CAP_HEADERS`):**

```
+------------------+---------------------------+---------------------+------------------+------------------------+------------------+
| Offset (8B)      | Timestamp Millis (8B)     | Header Count (2B)   | Headers (var)    | Payload Length (4B)    | Payload (var)    |
+------------------+---------------------------+---------------------+------------------+------------------------+------------------+
```

**Message entry (with both `CAP_MESSAGE_TTL` and `CAP_HEADERS`):**

```
+------------------+---------------------------+-----------------+---------------------+------------------+------------------------+------------------+
| Offset (8B)      | Timestamp Millis (8B)     | TTL ms (4B)     | Header Count (2B)   | Headers (var)    | Payload Length (4B)    | Payload (var)    |
+------------------+---------------------------+-----------------+---------------------+------------------+------------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Offset | u64 | Message offset within the topic. |
| Timestamp Millis | u64 | Publish timestamp (milliseconds since Unix epoch). |
| TTL ms | u32 | *(Only with `CAP_MESSAGE_TTL`)* Original TTL in milliseconds. 0 = no expiry. |
| Header Count | u16 | *(Only with `CAP_HEADERS`)* Number of header key-value pairs. May be 0. |
| Headers | repeated | *(Only with `CAP_HEADERS`)* `header_count` × header entry (see [Header Encoding](#header-encoding)). |
| Payload Length | u32 | Length of the payload in bytes. |
| Payload | bytes | Raw message payload. |

FetchResponse does not include `message_id` fields. Message IDs are for ack correlation in the push model — fetch consumers don't ack, so IDs are unnecessary overhead.

**Frame size truncation:** The entire FetchResponse frame must fit within `max_message_size` (default 1MB). If the requested `max_count` would produce a response exceeding this limit, the server returns fewer messages. Use `next_offset < head_offset` to detect that more messages are available.

**Oversized messages:** If the message at the requested offset individually exceeds the frame size limit, the server returns `Error(MessageTooLarge, 0x000E)` instead of a silent empty response. The client should skip the message (`offset + 1`) or use the HTTP fetch endpoint (default 4MB budget).

### 0x15 — FetchFrom

Pull messages starting from a timestamp. The server resolves the timestamp to an offset internally, then responds with a FetchResponse (0x14).

Requires only `Fetch` permission (not `Subscribe`).

```
+------------------+---------------------------+---------------------+
| Topic (str)      | Timestamp Millis (8B)     | Max Count (4B)      |
+------------------+---------------------------+---------------------+
```

| Field | Type | Description |
|---|---|---|
| Topic | string | Topic to fetch from. Must be non-empty. |
| Timestamp Millis | u64 | Fetch from the first message at or after this timestamp (milliseconds since epoch). If no message exists at or after this timestamp, the response has `count = 0` and `next_offset = head_offset`. |
| Max Count | u32 | Maximum messages to return. Must be > 0. |

### 0x16 — Nack

Sent by the client to explicitly reject a delivered message. Requires `CAP_NACK` capability. If a client without `CAP_NACK` sends this frame, the server responds with `Error { InvalidFrame }`.

Unlike Ack (fire-and-forget), Nack always returns a NackAck response.

```
+------------------------+-------------------+
| Message ID (8B)        | Reason (str)      |
+------------------------+-------------------+
```

| Field | Type | Description |
|---|---|---|
| Message ID | u64 | The message ID being rejected (from Deliver). |
| Reason | string | Human-readable reason for rejection (e.g., "invalid schema", "processing failed"). Maximum 1024 bytes — truncated on encode/decode if exceeded. |

**Behavior:** The server looks up the message in inflight tracking. If DLQ is enabled for the namespace, the message is routed to the dead-letter queue topic (`{topic}{dlq_suffix}`) with `DlqReason::ClientNack`. If DLQ is not enabled, the message is dropped from inflight. The result is reported via NackAck.

Nack is allowed during drain (it resolves inflight state, which helps drain complete).

### 0x17 — NackAck

Sent by the server in response to a Nack frame.

```
+------------------------+------------------+
| Message ID (8B)        | Status (1B)      |
+------------------------+------------------+
```

| Field | Type | Description |
|---|---|---|
| Message ID | u64 | Echoes the message ID from the Nack request. |
| Status | u8 | Result of the Nack (see table below). |

**Status codes:**

| Value | Name | Description |
|---|---|---|
| `0x00` | DeadLettered | Message was routed to the DLQ topic. |
| `0x01` | Dropped | DLQ is not enabled or not configured — message was removed from inflight without DLQ routing. |
| `0x02` | WriteFailed | DLQ write failed — message remains in inflight for redelivery. Client may retry Nack or let the inflight timeout handle it. |

### Header Encoding

When `CAP_HEADERS` is negotiated, Publish (0x07), Deliver (0x09), and FetchResponse (0x14) frames include a header section. Headers are key-value pairs of byte strings that travel with the message through publish, storage, delivery, and fetch.

**Header section layout:**

```
+---------------------+
| Header Count (2B)   |
+---------------------+
  repeated header_count times:
  +--------------------+------------------+---------------------+--------------------+
  | Key Length (2B)    | Key (N B)        | Value Length (4B)   | Value (N B)        |
  +--------------------+------------------+---------------------+--------------------+
```

| Field | Type | Description |
|---|---|---|
| Header Count | u16 | Number of header entries. 0 = no headers. |
| Key Length | u16 | Byte length of the header key. |
| Key | bytes | Header key (typically UTF-8, but opaque bytes on the wire). |
| Value Length | u32 | Byte length of the header value. Uses `u32` to support binary values. |
| Value | bytes | Header value (opaque bytes). |

**Validation limits (checked at publish time):**

| Limit | Default | Config Key |
|---|---|---|
| Max headers per message | 64 | `queue.max_headers_per_message` |
| Max header key size | 256 bytes | `queue.max_header_key_bytes` |
| Max header value size | 8,192 bytes | `queue.max_header_value_bytes` |
| Max total header bytes per message | 32,768 bytes | `queue.max_header_bytes_total` |

Exceeding any limit returns `Error { InvalidFrame }` with a descriptive message. Header bytes count toward the `max_message_size` frame limit.

**Reserved prefix:** Header keys starting with `_fafb.` are reserved for server-set headers (e.g., DLQ provenance metadata). Clients cannot publish messages with `_fafb.*` header keys — the server rejects them with `Error { InvalidFrame }`.

**Duplicate keys:** Allowed. Headers preserve insertion order. Same semantics as HTTP headers.

**Without `CAP_HEADERS`:** The header section is entirely absent — the wire format is identical to the legacy format. Messages published by legacy clients are stored and delivered with zero headers.

### Seek/Rewind: Consumer Group Semantics

When using `SubscribeAt` or `SubscribeFrom` with a consumer group:

- **Creating a new group:** The group's cursor starts at the requested offset. All members share this starting position.
- **Joining an existing group:** The new member joins at the group's *current* cursor position, regardless of the offset specified in `SubscribeAt`/`SubscribeFrom`. The requested offset is ignored because the group already has shared state. The `SubscribeAck.start_offset` reflects the group's current cursor position (not the requested offset). If the requested offset differs from the group's cursor, the server may send a non-fatal `OffsetNotAvailable` (0x000C) warning frame before the `SubscribeAck` to inform the client that its requested position was not honored.

This matches the expected behavior for consumer groups — the group cursor is shared state, not per-member. To replay a topic from a specific offset with a consumer group, create a new group name.

### Seek/Rewind: Offset Clamping

When the requested offset in `SubscribeAt` is not available (e.g., deleted by retention), the server:

1. Sends a non-fatal `Error` frame with code `OffsetNotAvailable` (0x000C) explaining the clamping
2. Sends a `SubscribeAck` with `start_offset` set to the oldest available offset

Clients should always check `start_offset` in the `SubscribeAck` rather than assuming the requested offset was honored.

## Error Codes

| Code | Name | Description | Fatal |
|---|---|---|---|
| `0x0001` | AuthFailed | API key invalid or namespace not found during Connect | Yes |
| `0x0002` | PermissionDenied | Client lacks required permission for the operation | No |
| `0x0003` | NamespaceNotFound | Namespace does not exist (post-auth, e.g., removed at runtime) | No |
| `0x0004` | TopicNotFound | Topic does not exist (only if auto-create is disabled) | No |
| `0x0005` | InvalidFrame | Malformed frame, unknown message type, or wrong direction | Yes |
| `0x0006` | FrameTooLarge | Frame length exceeds max_message_size | Yes |
| `0x0007` | UnsupportedVersion | Client requested a protocol version the server does not support | Yes |
| `0x0008` | NotAuthenticated | Client sent a non-Connect message before authentication | Yes |
| `0x0009` | InternalError | Unexpected server-side error | No |
| `0x000A` | ResourceExhausted | Resource limit reached (connections, disk quota) | No |
| `0x000B` | SlowConsumer | Consumer is too slow; connection will be closed | Yes |
| `0x000C` | OffsetNotAvailable | Requested offset has been deleted by retention (informational) | No |
| `0x000D` | ServerDraining | Request rejected because the server is draining (planned shutdown) | No |
| `0x000E` | MessageTooLarge | First message at requested offset exceeds frame size budget (Fetch only) | No |
| `0x000F` | InvalidTopic | Topic name is invalid: contains wildcard characters on publish, exceeds 255 bytes, has empty segments (`..`), or wildcard used without `CAP_WILDCARD` | No |
| `0x0010` | InvalidOperation | Operation not supported in the given context: `SubscribeAt` with wildcard pattern, or unsubscribing a specific topic within a wildcard subscription | No |

"Fatal" errors cause the server to close the connection after sending the Error frame. Non-fatal errors are informational — the connection remains open.

`ServerDraining` is returned when a client attempts `Subscribe` (including wildcard patterns), `SubscribeAt`, `SubscribeFrom`, or `Reconnect` during drain mode. It is distinct from `InternalError` — it tells the client "your request was rejected because of a planned shutdown, not because something broke." Publishes, Acks, and Fetch requests are always accepted during drain.

`MessageTooLarge` is returned when a Fetch or FetchFrom request hits a message that individually exceeds the frame size budget. The error message includes the stuck offset and message size. The client should skip (`offset + 1`) or use the HTTP fetch endpoint which has a larger default budget (4MB vs 1MB TCP frame limit). This error only fires for the **first** message — if subsequent messages would exceed the budget, the server simply returns fewer messages (normal truncation).

### Mapping to Error Enum

These wire error codes map to `src/error.rs` variants:

| Wire Code | `Error` Variant |
|---|---|
| `0x0001` | `AuthFailed` |
| `0x0002` | `PermissionDenied` |
| `0x0003` | `NamespaceNotFound` |
| `0x0004` | `TopicNotFound` |
| `0x0005` | `Codec` |
| `0x0006` | `Codec` |
| `0x0007` | `Codec` |
| `0x0008` | `AuthFailed` |
| `0x0009` | `Storage` / `Serialization` (context-dependent) |
| `0x000D` | (sent directly by session layer during drain) |
| `0x000E` | `MessageTooLarge` |
| `0x000F` | `InvalidTopic` |
| `0x0010` | `InvalidOperation` |

## Connection Lifecycle

1. Client establishes TCP connection.
2. Client sends **Connect** with namespace, API key, protocol version, and optional capabilities bitmask.
3. Server validates credentials and negotiates capabilities:
   - Success → server sends **ConnectAck** (status `0x00`, `server_capabilities = client_caps & SERVER_CAPABILITIES`). Connection is now authenticated with agreed capabilities.
   - Failure → server sends **ConnectAck** (status `0x01`) with error message, then closes connection.
4. Client may now send Subscribe, SubscribeAt, SubscribeFrom, Unsubscribe, Publish, Ack, Fetch, FetchFrom, and Ping messages.
5. Server may send SubscribeAck, UnsubscribeAck, PublishAck, Deliver, FetchResponse, Error, GoingAway, Ping, and Pong messages.
6. Either side may send **Ping** at any time; the other must respond with **Pong**.
7. Connection is closed by either side via TCP FIN, or by the server after a fatal Error.

**Alternative auth via Reconnect:** Instead of Connect, a client may send **Reconnect** (0x11) as its first frame to restore auth and subscriptions in one round-trip. This requires `CAP_RECONNECT` and is only valid in the pre-auth state.

**Note on wildcard subscriptions and Reconnect:** The Reconnect frame carries per-subscription `last_acked_offset`, which is per-topic. Wildcard subscriptions expand to N topics, each with its own offset. On Reconnect, clients should send one `SubscriptionEntry` per concrete topic (with the actual topic name, not the pattern). Alternatively, re-subscribe with the wildcard pattern from head after Reconnect. See [Wildcard Subscriptions](#wildcard-subscriptions) for details.

### Keepalive

Clients MUST send `Ping` (0x0C) frames periodically to prevent the server's idle timeout from closing the connection. The recommended interval is every 10 seconds. The server's default idle timeout is 30 seconds.

**Rules:**
- Any frame (not just Ping) resets the server's idle timer
- On busy connections where data flows regularly, explicit Pings are unnecessary
- Ping frames should only be sent when the connection is otherwise idle
- The server may also send Ping frames; clients MUST reply with Pong (0x0D)
- Keepalive applies to all states: Authenticated, Subscribed, and Draining

**Failure to send keepalive Pings on an idle connection will result in disconnection with `Error { InternalError, "idle timeout" }`.**

The `ping_interval` should be at most one-third of the server's `idle_timeout_secs` to allow margin for network jitter and scheduling delays (e.g., 10s ping interval with 30s server timeout).

### Pre-Auth Restrictions

Before a successful Connect/ConnectAck or Reconnect/ReconnectAck exchange, the server rejects any message other than Connect or Reconnect with Error code `0x0008` (NotAuthenticated) and closes the connection.

### Drain Lifecycle

When the server enters drain mode (via SIGTERM or `POST /admin/drain`):

1. Server stops accepting new TCP connections
2. For each connected client:
   - If `CAP_GOING_AWAY` negotiated → sends **GoingAway** frame with reason and deadline
   - If no `CAP_GOING_AWAY` → sends **Error** with `ServerDraining` code
3. Session transitions to `Draining` state — new subscribes rejected, publishes/acks/unsubscribes still allowed
4. Server waits for `drain_timeout_secs` (clients should disconnect voluntarily)
5. Server transitions to `ShuttingDown` — remaining connections are force-closed

## Frame Dispatch

When the TCP server receives a decoded frame, it dispatches to the appropriate subsystem:

| Frame Received | Handler | Response Frame |
|---|---|---|
| Connect | Auth (`Authenticator` trait) | ConnectAck or Error |
| Reconnect | Auth + NamespaceManager (batch subscribe) | ReconnectAck or Error |
| Subscribe | NamespaceManager | SubscribeAck or Error |
| SubscribeAt | NamespaceManager | SubscribeAck or Error (+ OffsetNotAvailable warning) |
| SubscribeFrom | NamespaceManager | SubscribeAck or Error |
| Unsubscribe | NamespaceManager | UnsubscribeAck or Error |
| Publish | NamespaceManager → QueueEngine | PublishAck or Error |
| Ack | NamespaceManager delivery tracking | (none) |
| Fetch | NamespaceManager (stateless read) | FetchResponse or Error (+ OffsetNotAvailable warning) |
| FetchFrom | NamespaceManager (timestamp → offset → stateless read) | FetchResponse or Error |
| Nack | NamespaceManager DLQ routing | NackAck |
| Ping | TCP connection handler | Pong |

GoingAway is server-initiated (not dispatched from a received frame). It is sent by the connection handler when the server lifecycle transitions to `Draining`.

Fetch and FetchFrom do NOT create any server-side state (no subscriptions, delivery tasks, or inflight tracking). They are stateless request/response operations that can be freely interleaved with Subscribe/Publish on the same connection.

## Wildcard Subscriptions

Wildcard subscriptions allow a client to match multiple topics with a single subscribe request using NATS-style patterns. This requires `CAP_WILDCARD` capability negotiation. See [wildcard_subscriptions.md](wildcard_subscriptions.md) for a usage guide with examples.

### Wildcard Syntax

Topic names are split on `.` (dot) into segments. Two wildcard tokens are supported:

| Token | Meaning | Example | Matches | Does Not Match |
|---|---|---|---|---|
| `*` | Exactly one segment | `orders.*` | `orders.us`, `orders.eu` | `orders`, `orders.us.east` |
| `>` | One or more trailing segments | `orders.>` | `orders.us`, `orders.us.east` | `orders` |

**Rules:**
- `>` must be the last segment. `orders.>.foo` is invalid.
- `*` can appear in any position except after `>`.
- Empty segments (`orders..foo`, `..`, `.`) are invalid.
- A pattern of just `*` matches any single-segment topic. A pattern of just `>` matches any topic.
- Patterns can combine literals and wildcards: `events.*.created` matches `events.user.created`.

### Topic Name Validation

Topic names are validated as follows:
- **Publish-side:** Topics must not contain `*` or `>` characters — these are reserved for subscription patterns. Maximum length is 255 bytes. Empty segments (`..`) are rejected. Violation returns `ErrorCode::InvalidTopic`.
- **Subscribe-side:** `*` and `>` are allowed and indicate a wildcard pattern.

### Wildcard Subscribe Behavior

When a client sends `Subscribe { topic: "orders.*" }` with `CAP_WILDCARD` negotiated:

1. Server parses and validates the wildcard pattern
2. Server finds all existing topics matching the pattern
3. For each matching topic, a delivery forwarder is created (messages fan-in to the subscriber's single delivery channel)
4. Pattern is registered in the server's subscription trie for future topic matching
5. Server sends `SubscribeAck { topic: "orders.*", start_offset: 0, matched_count: N }`

Messages from all matching topics are multiplexed onto the subscriber's delivery channel. The `topic` field in each `Deliver` frame identifies which topic the message came from.

### Auto-Subscribe on New Topics

When a new topic is created via publish that matches an existing wildcard subscription, the server automatically starts delivering from the new topic (from offset 0). This is the key difference from static pattern matching — wildcard subscriptions are live.

Example: Subscribe to `orders.*` → later, a producer publishes to `orders.apac` for the first time → the subscriber immediately begins receiving from `orders.apac`.

A configurable limit `max_topics_per_wildcard` (default 1000) caps how many topics a single wildcard subscription can match. If exceeded during auto-subscribe, the new topic is skipped with a warning log.

### Wildcard Consumer Groups

Wildcard subscriptions are compatible with consumer groups. Group members collectively consume from all matching topics with per-topic round-robin semantics. Each matched topic gets its own consumer group offset tracking.

When a new member joins an existing wildcard group, they are added to all per-topic consumer groups. When a new topic matches the pattern, all group members are added to the new topic's consumer group.

### Wildcard Limitations

| Operation | Wildcard Support |
|---|---|
| `Subscribe` (head) | Supported — starts at head of each matching topic |
| `SubscribeFrom` (timestamp) | Supported — timestamp resolved per topic |
| `SubscribeAt` (offset) | **Not supported** — returns `ErrorCode::InvalidOperation` |
| `Fetch` / `FetchFrom` | **Not supported** — returns `ErrorCode::InvalidTopic` |
| `Unsubscribe` (pattern) | Supported — removes pattern and all per-topic forwarders |
| `Unsubscribe` (specific topic within wildcard) | **Not supported** — returns `ErrorCode::InvalidOperation` |

### Reconnect with Wildcard Subscriptions

The `Reconnect` frame carries per-subscription `last_acked_offset`, which is per-topic. Wildcard subscriptions expand to N topics, each with its own offset. On Reconnect, wildcard subscriptions should be handled by sending one `SubscriptionEntry` per concrete topic (with the actual topic name, not the pattern). Alternatively, clients can re-subscribe with the wildcard pattern from head after Reconnect.

Clients needing offset-exact recovery should use per-topic `SubscribeAt` after Reconnect rather than wildcard re-subscribe.

## Design Notes

- **No compression in v1.** Payloads are sent as-is. Compression may be added via a new capability bit negotiated in the Connect handshake (see [Capability Negotiation](#capability-negotiation)).
- **No reply-to / request-response in v1.** Publish is fire-and-deliver. Request-response patterns can be built by clients using topic conventions.
- **No batching in v1.** Each frame carries exactly one message. Batched publish may be added as a new message type in a future version.
- **Single namespace per connection.** A client authenticates to one namespace. To work with multiple namespaces, open multiple connections.

## Shared Error Codes (TCP / HTTP)

The TCP protocol and HTTP API share a common set of error semantics to ensure consistent client handling regardless of transport.

| Wire Code | Name | TCP Frame | HTTP Status |
|---|---|---|---|
| `0x0001` | AuthFailed | Error frame | 401 Unauthorized |
| `0x0002` | PermissionDenied | Error frame | 403 Forbidden |
| `0x0003` | NamespaceNotFound | Error frame | 404 Not Found |
| `0x0004` | TopicNotFound | Error frame | 404 Not Found |
| `0x0006` | FrameTooLarge | Error frame | 413 Payload Too Large |
| `0x0009` | InternalError | Error frame | 500 Internal Server Error |

## Future Considerations

- **WebSocket upgrade**: HTTP WebSocket upgrade for subscriptions is a future consideration. This would allow browser clients and environments where raw TCP is unavailable to receive push delivery. The subscription semantics would mirror the TCP Subscribe/Deliver/Ack flow over WebSocket frames. Not planned for v1.
