# Quickring Wire Protocol — v1

**Status:** v0 implemented (2026-06-06, tag `v0`) — the v0 subset (sessions,
pub/sub, presence digest) is live in the hub (`quickring/hub`), Fabric Kit, and
the v0 agent. Pairing, catch-up, `DPresence`, unsubscribe, dedup, rate limits,
and E2E remain spec-only. Canonical schema: [`../proto/quickring/v1/quickring.proto`](../proto/quickring/v1/quickring.proto).
**Audience:** Quickring hub developers, SDK developers, third-party
integrators.
**Scope:** The full Quickring wire standard — both the **base layer** (the
envelope, tags, the T/R/D grammar, errors, addressing) and the **fabric layer**
(sessions, networked pub/sub, pairing, presence, offline catch-up, E2E) — spoken
between Quickring agents and the Quickring hub over WebSocket. The canonical
schema is [`../proto/quickring/v1/quickring.proto`](../proto/quickring/v1/quickring.proto);
this document is normative for semantics. See "Protocol layering" for who
*implements* each layer.

---

## Protocol layering

Quickring's standard is two layers, **both owned by Quickring** and both defined
here (ADR-0002). The split is who *implements* each layer, not who owns it.

| Layer | Covers | Implemented by |
|-------|--------|----------------|
| Base layer | the envelope (`tag` + `oneof`), tag correlation, the T/R/D grammar, the `RError` mechanism, the addressing model | DeviceNix **message-kit** on-device (and any client's transport core) |
| Fabric layer | session lifecycle, networked pub/sub wire, pairing, presence, offline catch-up, E2E encryption, the feed convention | the Quickring daemon / hub |

The canonical Envelope is defined in this standard's `.proto`
([`../proto/quickring/v1/quickring.proto`](../proto/quickring/v1/quickring.proto)) —
**not** imported from elsewhere. DeviceNix message-kit is a *conforming
implementation* that adopts the Quickring envelope as its canonical on-device bus
format: a message is one Quickring envelope end to end, and crossing the box
boundary only swaps the *transport* (`AF_UNIX` socket ↔ WebSocket). The Quickring
daemon registers with the message-kit router as the **remote-transport provider**.

> **Note (ADR-0002, 2026-06-05):** earlier drafts said the envelope was owned by
> the messagekit base protocol and merely *imported* by Quickring. That is now
> reversed — the envelope is part of the Quickring standard, defined here, and
> message-kit implements it. The "The envelope" and "Tag discipline" sections
> below are therefore **canonical**, not historical. QR-14 tracks any further doc
> reorganization; QR-13 the DeviceNix ↔ Quickring integration.

---

## Design ethos

Quickring's wire protocol is heavily influenced by **Plan 9's 9P
protocol** — not in encoding (we use protobuf, not 9P's hand-rolled
binary format), but in *discipline*:

- A small, fixed set of message types. v1 has 21 envelope variants
  (6 T/R pairs, plus `RError` and four D-pushes); aim to stay under
  30 for the life of the protocol. The discipline is the point, not
  a hard number — every new type should earn its place.
- Every client request is paired with exactly one server response,
  correlated by a per-message **tag**.
- Server-pushed messages (deliveries, presence updates) are a
  separate, visibly-distinct category.
- Multi-step interactions use server-issued session handles instead
  of inventing new message types per step.
- Errors are first-class: any request can be answered with an error
  response carrying the same tag.
- The protocol is versioned from message one. Version negotiation
  happens before any other traffic.

The goal: a developer reading a packet capture should be able to tell
instantly which direction a message flows, whether it expects a
reply, and what interaction it's part of.

---

## Transport

Quickring is two services, not one. Don't conflate them:

- **`api.quickring.me`** — a conventional HTTP API (signup, account
  recovery, initial auth-token exchange, device registration).
  Request/response, no long-lived connections. **Rust + axum** per the
  Lockamy Studios language convention locked 2026-05-30 (pivoted from
  Go + Gin); consumes Service Kit (LS-41) for scaffolding. This
  document does **not** cover the HTTP API.
- **`ws.quickring.me`** — the hub: the long-lived WebSocket service
  this document specifies. Implemented in Rust on top of `axum`'s
  WebSocket support (`axum::extract::ws`), which sits on `tokio` +
  `hyper` + `tokio-tungstenite`. The one-reader / one-writer concurrency
  model the hub needs maps directly onto tokio's async task pattern
  (a read task + a write task per connection sharing the WebSocket
  via `SplitSink` / `SplitStream`).

- **Channel:** WebSocket. TLS terminated at Cloudflare; origin uses
  Cloudflare Origin Certificates.
- **Endpoint:** `wss://ws.quickring.me/v1`
- **Subprotocol:** `quickring.v1` (negotiated in the WebSocket
  handshake; future protocol versions get new subprotocol strings).
- **Framing:** One protobuf-encoded `Envelope` per WebSocket binary
  frame. No multi-frame messages in v1.
- **Max message size:** 1 MiB. Larger payloads (file transfer) are
  handled out-of-band via WebRTC or Bitchain in v1.5+ and never
  traverse the hub.
- **Timestamps:** All timestamps on the wire are unix milliseconds,
  UTC. No exceptions, no local time, no timezone offsets. In Go this
  is `time.Now().UTC().UnixMilli()`; in Dart, `DateTime.now()
  .toUtc().millisecondsSinceEpoch`. Display-local conversion happens
  only at the UI edge, never on the wire.

---

## Message categories

Every message belongs to exactly one of three categories, indicated
by the prefix of its type name:

| Prefix | Direction | Semantics |
|---|---|---|
| **T** | Client → Server | A client request. Carries a tag. The server **must** respond with exactly one matching R-message or RError carrying the same tag. |
| **R** | Server → Client | A server response to a previously-received T-message. Carries the same tag as the request. |
| **D** | Server → Client | A server-pushed message, unsolicited. Carries `tag = 0`. The client does not respond. |

This three-way split is the core discipline. When designing a new
interaction, the first question is: "Is this T/R (request/response)
or D (push)?" There is no fourth category.

---

## The envelope

Every message on the wire is a single `Envelope`:

```protobuf
syntax = "proto3";
package quickring.v1;

message Envelope {
  // Correlation tag. Echoed by the server in the matching R-message
  // or RError. Set to 0 for D-messages. Every T-message in v1
  // expects a response and therefore carries a non-zero tag; there
  // are no fire-and-forget T-messages in v1 (TPing included — it
  // always gets an RPing).
  uint32 tag = 1;

  // Exactly one of these is set per envelope.
  oneof body {
    // Session lifecycle
    TVersion     t_version     = 10;
    RVersion     r_version     = 11;
    THello       t_hello       = 12;
    RHello       r_hello       = 13;
    TPing        t_ping        = 14;
    RPing        r_ping        = 15;

    // Topic operations
    TSubscribe   t_subscribe   = 20;
    RSubscribe   r_subscribe   = 21;
    TUnsubscribe t_unsubscribe = 22;
    RUnsubscribe r_unsubscribe = 23;
    TPublish     t_publish     = 24;
    RPublish     r_publish     = 25;

    // Offline catch-up
    TCatchUp     t_catchup     = 30;
    RCatchUp     r_catchup     = 31;

    // Device pairing
    TPair        t_pair        = 40;
    RPair        r_pair        = 41;

    // Error response (replaces any R-message)
    RError       r_error       = 99;

    // Server-pushed deliveries (D-messages)
    DDeliver     d_deliver     = 100;
    DPresence    d_presence    = 101;
    DPairClaim   d_pair_claim  = 102;
    DPairResult  d_pair_result = 104;
  }
}
```

Every message type defined anywhere in this document appears in this
`oneof`. There are no "implied" or "to-be-added" variants; if a
message isn't here, it isn't part of v1.

(Field number 103 is intentionally skipped to leave 102–103 grouped
as the pairing D-pushes' immediate neighbourhood while keeping the
slot open for a future `DPair*` push without renumbering.)

The `oneof` makes the message type explicit and allows the protocol
to add new types in future versions without breaking older clients
(they ignore unknown variants).

---

## Tag discipline

- Tags are 32-bit unsigned integers, allocated by the client.
- A client must not reuse a tag while a response is still
  outstanding for it.
- Tag `0` is reserved for D-messages (server pushes). Every
  T-message in v1 carries a non-zero tag and expects a response.
- Clients may allocate tags sequentially or randomly; the server
  does not interpret tag values, only echoes them.
- The server tracks outstanding tags per connection. If a client
  sends a T-message with a tag that is already in flight, the server
  responds with `RError(tag=duplicate_tag, code=TAG_IN_USE)`.

**One T, one R.** Every T-message receives exactly one terminal
response — either its matching R-message or an `RError` — carrying
the same tag, after which the tag is free for reuse. A T-message
**never** produces two responses on the same tag. Where an
interaction needs to signal something later (e.g. device-pairing
approval, which waits on a human), that later signal is a
D-message on `tag = 0`, not a second R. This rule is what lets the
SDK model every request as a single promise that resolves or
rejects exactly once.

The client correlates an `RError` to its originating request purely
by tag. `RError` deliberately does **not** carry a field naming
which request type it answers — the SDK already holds the
tag → request-type mapping, and duplicating it on the wire would be
redundant and another thing to keep consistent.

The SDK shields developers from tag allocation entirely; tags are an
implementation detail of the protocol layer.

---

## Session lifecycle

A Quickring session is the lifetime of one authenticated WebSocket
connection. Every session traverses these states in order:

```
   (connected) ──> Version ──> Hello ──> Ready ──> (closed)
```

### 1. Version negotiation

The **first message** on every connection, in both directions, is a
version negotiation. No other traffic is permitted until version is
agreed.

```protobuf
message TVersion {
  // Highest protocol version the client supports.
  // For v1 this is exactly "quickring.v1".
  string version = 1;

  // Maximum envelope size the client is willing to receive, in bytes.
  // Informational; servers may use this to chunk large RCatchUp
  // responses. The hard ceiling is 1 MiB regardless of this value.
  uint32 max_envelope_size = 2;
}

message RVersion {
  // Highest version both client and server support. May be lower
  // than the client's offered version if the server supports an
  // older protocol.
  string version = 1;

  // Server's max envelope size (the actual ceiling for this
  // connection: min of client offer and server limit).
  uint32 max_envelope_size = 2;
}
```

If the server cannot agree on a version (the client offered a
version the server does not implement), the server responds with
`RError(code=VERSION_INCOMPATIBLE)` and closes the connection.

Version strings follow the form `quickring.v<major>`. Within a
major version, the protocol is backward-compatible: a v1.3 server
accepts v1.0 clients. Breaking changes bump the major version. The
hub supports `N` and `N+1` simultaneously for a minimum of 90 days
during a major version transition.

### 2. Hello (authentication)

Once version is agreed, the client authenticates and identifies its
device.

```protobuf
message THello {
  // Authentication credential. Modeled as a oneof from day one so
  // that adding client-certificate or device-key-challenge auth in
  // a later version is an additive change, not a breaking one. v1
  // implements ONLY the bearer_token arm; the others are reserved
  // and rejected with RError(code=AUTH_INVALID) if sent.
  oneof auth {
    // Bearer token issued by the HTTP auth flow at api.quickring.me.
    string bearer_token = 1;
    // Reserved for v2+. Not implemented in v1.
    // bytes client_cert = 5;
    // bytes device_key_challenge_response = 6;
  }

  // The device's stable identifier (UUID). Issued at first device
  // registration; persisted in local storage on the agent.
  string device_id = 2;

  // Optional resume token from a previous session on this device.
  // Presenting a valid resume_token lets the server re-establish
  // the session's prior subscriptions without a fresh round of
  // TSubscribe calls. It does NOT itself replay missed messages —
  // to recover messages sent while offline, the client issues
  // TCatchUp per subscription after RHello. If absent or invalid,
  // the client re-subscribes from scratch.
  optional bytes resume_token = 3;

  // Client metadata for diagnostics. Not used for any auth decision.
  ClientInfo client_info = 4;
}

message ClientInfo {
  string platform   = 1;  // "macos", "ios", "android", "web", "linux-headless", "thunderhead"
  string sdk_name   = 2;  // "quickring-dart", "quickring-go", "quickring-js", or third-party identifier
  string sdk_version = 3; // semver
  string app_name   = 4;  // optional; for third-party integrations
}

message RHello {
  // Session identifier. Useful for log correlation; not security-sensitive.
  string session_id = 1;

  // Account this device belongs to.
  string account_id = 2;

  // Resume token to present in the next THello to resume from this
  // point. Rotated each session.
  bytes resume_token = 3;

  // Snapshot of other devices on this account and their current
  // presence. Lets the client populate its UI immediately without
  // needing to wait for DPresence pushes.
  repeated DevicePresence presence_digest = 4;

  // Server time at the moment of RHello, in unix milliseconds.
  // Clients use this to align local clocks for sort order and TTLs.
  int64 server_time_ms = 5;
}

message DevicePresence {
  string device_id = 1;
  PresenceState state = 2;
  int64 last_seen_ms = 3;
  ClientInfo client_info = 4;
}

enum PresenceState {
  PRESENCE_UNKNOWN = 0;
  PRESENCE_OFFLINE = 1;
  PRESENCE_ONLINE  = 2;
  PRESENCE_AWAY    = 3;
}
```

Auth failures (`RError(code=AUTH_INVALID)`, `RError(code=AUTH_EXPIRED)`)
close the connection. The client should re-acquire an auth token via
the HTTP API and reconnect.

### 3. Ready

After `RHello`, the connection is ready for normal traffic. The
client may now issue subscribes, publishes, catch-ups, and pairing
requests. The server may now push `DDeliver` and `DPresence`.

### Heartbeats

```protobuf
message TPing {
  // Optional client-side timestamp for round-trip latency measurement.
  int64 client_time_ms = 1;
}

message RPing {
  int64 server_time_ms = 1;
  int64 client_time_ms = 2;  // Echoed from request, if provided
}
```

Clients send `TPing` every 30 seconds while idle. The server uses
the heartbeat to refresh the device's presence TTL in Redis. A
connection with no traffic (including pings) for 90 seconds is
considered abandoned and closed by the server.

---

## Topic operations

### Topic naming convention

Topics are slash-delimited strings, case-sensitive, ASCII only.
Components:

| Prefix | Shape | Purpose |
|---|---|---|
| `account/<account_id>/device/<device_id>` | Targeted | Deliver to one specific device on an account. |
| `account/<account_id>/broadcast` | Fanout | Deliver to all online devices on an account ("carbons"). |
| `account/<account_id>/feed` | Feed | The account's feed (posts + comments, as envelope-typed payloads). |
| `dm/<a>/<b>` | Direct message | DMs between two accounts. The two account ids are sorted ASCII-ascending and joined with `/`. |
| `team/<team_id>/...` | Team | **Reserved for v2.5.** Do not use in v1. |

Topic names are validated at subscribe / publish time. Invalid names
return `RError(code=TOPIC_INVALID)`.

Authorization for each topic is enforced server-side:

- A device may subscribe to any topic under its own `account/<account_id>/...`.
- A device may subscribe to a `dm/<a>/<b>` topic if its account is
  one of `a` or `b`.
- A device may publish to `account/<own_id>/...` and to
  `dm/<own_id>/<other>`.
- Cross-account publishing requires that the recipient has accepted
  a contact relationship (mechanism out of scope for this doc;
  enforced at the HTTP API layer).

### Subscribe

```protobuf
message TSubscribe {
  string topic = 1;

  // Optional: only receive deliveries published after this server
  // timestamp. Useful for resuming subscriptions across reconnects.
  // If omitted, the subscription is forward-only from now.
  optional int64 since_ms = 2;
}

message RSubscribe {
  // Server-issued handle for this subscription. Echoed in DDeliver
  // messages so the client knows which subscription a delivery
  // belongs to.
  string subscription_id = 1;

  // Resolved topic (in case the server canonicalised something).
  string topic = 2;

  // Server time at subscribe; client should use this to anchor
  // any since_ms it sends on a future TCatchUp for this topic.
  int64 server_time_ms = 3;
}
```

A successful `TSubscribe` returns a `subscription_id` that the
server uses in subsequent `DDeliver` messages. Multiple subscriptions
to the same topic are allowed and return distinct `subscription_id`s
(useful for SDK consumers that wire two independent handlers).

### Unsubscribe

```protobuf
message TUnsubscribe {
  string subscription_id = 1;
}

message RUnsubscribe {
  // Empty on success.
}
```

After `RUnsubscribe`, the server stops pushing `DDeliver` messages
for that subscription_id. Any DDeliver already in flight may still
arrive at the client; the client should ignore deliveries for
unknown subscription_ids.

### Publish

```protobuf
message TPublish {
  string topic = 1;

  // Opaque payload. The hub does not interpret payload bytes.
  // For E2E-encrypted topics this is ciphertext; the cleartext
  // structure is defined by the application layer (e.g. feed
  // posts, DMs, presence-rich-state, file-transfer signaling).
  bytes payload = 2;

  // Optional client-assigned message id for deduplication on
  // retry. If omitted, the server assigns one. Deduplication is
  // scoped to (account_id, client_message_id) and held in Redis
  // with a 10-minute TTL — NOT scoped to the connection. This is
  // deliberate: the case a client_message_id exists to handle is
  // "connection dropped mid-publish, client doesn't know if it
  // landed, reconnects on a new connection, retries." A
  // connection-scoped dedup would miss exactly that case. If a
  // TPublish arrives whose (account_id, client_message_id) the hub
  // has seen within the TTL window, the hub returns the original
  // RPublish (same message_id, same server_time_ms) instead of
  // publishing again. Outside the TTL window, a repeat is treated
  // as a new publish.
  optional string client_message_id = 3;

  // Optional payload type hint for client-side routing. Not
  // interpreted by the hub. Convention:
  //   "post" — feed post
  //   "comment" — feed comment
  //   "dm" — direct message
  //   "presence_rich" — extended presence state
  //   "signal" — WebRTC signaling for file transfer (v1.5+)
  //   "bitchain_ref" — Bitchain manifest reference, payload contains
  //                    a bitchain:// URI or serialised manifest pointer
  //                    (v1.5+; see file transfer note below)
  optional string payload_type = 4;
}

message RPublish {
  // Server-assigned message identifier: a ULID generated by the
  // hub. ULID gives time-ordered, globally-unique ids with no
  // cross-instance coordination (unlike a per-topic counter, which
  // would be a coordination point across hub instances behind
  // Redis). Globally unique, not merely per-topic.
  string message_id = 1;

  // Server-assigned timestamp (unix ms UTC). Redundant with the
  // ULID's embedded time component for ordering, but retained as
  // an explicit field so clients never have to parse a ULID to
  // sort. The ULID is the tiebreaker when two messages share a
  // millisecond.
  int64 server_time_ms = 2;
}
```

The hub does not read payload contents. All payload interpretation
happens at the application layer in agents and SDKs. This is the
load-bearing decision that makes the hub an opaque relay; see the
project brief at `agents/projects/quickring.md` for the locked E2E
stance.

---

## Server-pushed messages

### DDeliver

The server pushes `DDeliver` whenever a message lands on a topic
the client is subscribed to.

```protobuf
message DDeliver {
  // Which subscription this delivery satisfies.
  string subscription_id = 1;

  // The topic the message was published to (may be redundant with
  // the subscription, but explicit for SDK debugging).
  string topic = 2;

  // Server-assigned message id (matches the RPublish from the sender).
  string message_id = 3;

  // Sender's device id. Useful for filtering "my own" messages
  // on broadcast topics where the sender also receives the delivery.
  string sender_device_id = 4;

  // Sender's account id.
  string sender_account_id = 5;

  // Server timestamp the message was published (sort key within topic).
  int64 server_time_ms = 6;

  // The opaque payload bytes from the publisher.
  bytes payload = 7;

  // Echoed payload_type hint from publisher, if any.
  optional string payload_type = 8;
}
```

Tag is always `0`. Clients do not respond to DDeliver.

### DPresence

The server pushes `DPresence` whenever the presence state of a
device the client is "watching" changes. In v1, every client
automatically watches presence for all other devices on its own
account; cross-account presence subscriptions are a v2 feature.

```protobuf
message DPresence {
  string device_id = 1;
  string account_id = 2;
  PresenceState state = 3;
  int64 last_seen_ms = 4;

  // Only populated when state transitions to ONLINE.
  optional ClientInfo client_info = 5;
}
```

Tag is always `0`.

---

## Offline catch-up

```protobuf
message TCatchUp {
  // The subscription to catch up. Implies the topic and authorization.
  string subscription_id = 1;

  // Receive messages with server_time_ms > since_ms.
  int64 since_ms = 2;

  // Maximum messages to return in this batch. The server may return
  // fewer. If the catch-up set exceeds this limit, RCatchUp signals
  // has_more = true and the client should issue a follow-up TCatchUp
  // with since_ms set to the last returned message's server_time_ms.
  uint32 limit = 3;
}

message RCatchUp {
  // Replayed deliveries. Same shape as DDeliver. The client should
  // process these identically to live deliveries — the SDK is
  // expected to unify the two paths.
  repeated DDeliver deliveries = 1;

  // True if there are more messages beyond this batch.
  bool has_more = 2;

  // Server time at the moment of RCatchUp. Use this (not the last
  // delivery's server_time_ms) as the next since_ms to avoid race
  // conditions with concurrent publishes.
  int64 server_time_ms = 3;
}
```

The server's short-TTL message store (DynamoDB) retains messages
for **7 days** in v1. `TCatchUp` with `since_ms` older than 7 days
returns `RError(code=CATCHUP_HORIZON_EXCEEDED)` and the client
should treat its local state as authoritative going forward.

---

## Device pairing

Pairing is the multi-step interaction that adds a new device to an
existing account. It's the "scan a QR code, done" experience from
the brief, and the most complex flow in v1. Modeled after 9P's
`Twalk`: one message type with multiple step kinds, carrying a
server-issued session id across steps.

### First-account bootstrap is out of scope

Pairing adds a device to an account that **already has** at least one
trusted device. How the *first* device on a brand-new account comes
to exist is deliberately **not** part of this protocol — there is no
device to pair from, so the wire stays silent on it and keeps the
standard vendor-neutral. An implementation is free to bootstrap the
first device however it likes (e.g. self-issuing account keys
locally). The first device then looks like any other account device
to the wire, and all subsequent devices join through ordinary
pairing.

Quickring (the hosted product) bootstraps via an operator-held
**"device 0"** provisioned at web/API signup: device 0 holds the
account's first key and plays the existing-device role in the
ordinary pairing flow for the user's first real device, and doubles
as a user-controllable recovery anchor. That is a deployment choice
above the protocol, recorded in **ADR-0004**; the wire below is
unchanged.

### The flow

```
Existing device                   Hub                   New device
───────────────                   ───                   ──────────
TPair(step=INITIATE) ──────────────►
                  ◄──────────── RPair(pair_session_id, qr_payload)
[displays QR code]

                                                       [scans QR;
                                                        extracts
                                                        pair_session_id
                                                        and bootstrap
                                                        endpoint]

                                                       (opens WebSocket)
                                                       TVersion / RVersion
                                                       (no THello — paired
                                                        identity is being
                                                        established here)

                                  ◄──────────────────  TPair(step=CLAIM,
                                                              pair_session_id,
                                                              new_device_pubkey,
                                                              proposed_device_info)
                                  ──────────────────►  RPair(step=CLAIMED)
                                                       [terminal response to
                                                        the CLAIM tag; new
                                                        device now waits for
                                                        a DPairResult push]
                  ◄──────────── DPairClaim
                                  (notify existing
                                   device of claim)
[displays "Pair
 this device?"
 prompt with
 fingerprint of
 new_device_pubkey]

TPair(step=APPROVE,
      pair_session_id,
      signed_challenge) ──────────►
                  ◄──────────── RPair(step=COMPLETE)
                                  (terminal response to
                                   the APPROVE tag)
                                  ──────────────────►  DPairResult(approved,
                                                              account_credentials,
                                                              device_id)
                                                       [persists creds,
                                                        opens a fresh
                                                        connection, sends
                                                        THello]
```

The key discipline point: the new device's `TPair(CLAIM)` gets an
**immediate** terminal `RPair(CLAIMED)` — it does not block waiting
for a human to approve on the other device. Approval (or rejection,
or timeout) arrives later as a `DPairResult` server-push on
`tag = 0`. This keeps the "one T, one R" rule intact: no request is
ever left hanging on human latency, and no tag ever receives two
responses. `DPairClaim` (to the existing device) and `DPairResult`
(to the new device) are both D-messages, defined below.

### TPair / RPair shape

```protobuf
message TPair {
  PairStep step = 1;
  oneof step_body {
    TPairInitiate initiate = 2;
    TPairClaim    claim    = 3;
    TPairApprove  approve  = 4;
  }
}

enum PairStep {
  PAIR_STEP_UNKNOWN  = 0;
  PAIR_STEP_INITIATE = 1;  // From existing device: start a pairing
  PAIR_STEP_CLAIM    = 2;  // From new device: claim the pair session via the QR payload
  PAIR_STEP_APPROVE  = 3;  // From existing device: approve the claim
}

message TPairInitiate {
  // Optional human-readable label for the pairing session, shown
  // in logs ("Pairing initiated from MacBook Pro").
  string label = 1;
}

message TPairClaim {
  // The pair_session_id encoded in the QR code.
  string pair_session_id = 1;

  // The new device's freshly-generated public key. Used by the
  // existing device to display a fingerprint for confirmation.
  bytes new_device_pubkey = 2;

  // Self-reported device info.
  ClientInfo client_info = 3;

  // Proposed human-readable device name ("Doug's iPad").
  string proposed_device_name = 4;
}

message TPairApprove {
  string pair_session_id = 1;

  // Signature over the new_device_pubkey by an existing-device
  // signing key, proving the user on the existing device approved.
  bytes signature = 2;
}

message RPair {
  PairStep step = 1;

  oneof step_body {
    RPairInitiated initiated = 2;  // → existing device, response to INITIATE
    RPairClaimed   claimed   = 3;  // → new device, response to CLAIM
    RPairComplete  complete  = 4;  // → existing device, response to APPROVE
  }
}

message RPairInitiated {
  // Server-issued session id; encoded into the QR shown by the
  // existing device. Short-lived (60 seconds).
  string pair_session_id = 1;

  // QR payload to display. Includes: pair_session_id, the WebSocket
  // bootstrap endpoint, and a short-lived single-use bootstrap
  // credential. Encoded as a URL: quickring://pair?...
  string qr_payload = 2;

  // Server-side TTL for the pair session, in unix ms.
  int64 expires_at_ms = 3;
}

message RPairClaimed {
  // Terminal response to the new device's TPair(CLAIM). Acknowledges
  // the claim was registered; it does NOT carry credentials. The
  // actual approval happens out of band on the existing device and
  // arrives later as a DPairResult push (see below). This split is
  // what keeps the "one T, one R" rule intact — the CLAIM tag is
  // resolved immediately, not held open across human latency.
  string pair_session_id = 1;

  // When the pair session expires if no approval arrives. After
  // this, the new device should expect a DPairResult with
  // outcome = OUTCOME_TIMEOUT (or simply give up).
  int64 expires_at_ms = 2;
}

message RPairComplete {
  // Terminal response to the existing device's TPair(APPROVE).
  // Confirms the hub accepted the approval and has dispatched (or
  // will dispatch) credentials to the new device via DPairResult.
  // The pair session is now closed.
  string new_device_id = 1;
  string new_device_name = 2;
}

message DPairClaim {
  // Pushed to existing device(s) on the account when a new device
  // claims a pair session. Lets the existing device prompt the
  // user with the new device's fingerprint and proposed name.
  // Tag is always 0.
  string pair_session_id = 1;
  bytes new_device_pubkey = 2;
  string proposed_device_name = 3;
  ClientInfo new_device_client_info = 4;
}

message DPairResult {
  // Pushed to the NEW device to report the final outcome of a
  // pairing it claimed. This is the asynchronous counterpart to
  // RPairClaimed: the new device received an immediate RPairClaimed
  // for its CLAIM tag, then waits for this push. Tag is always 0.
  string pair_session_id = 1;

  PairOutcome outcome = 2;

  // Populated only when outcome == OUTCOME_APPROVED. These are the
  // credentials the new device persists and uses for its first
  // THello on a fresh connection.
  optional PairCredentials credentials = 3;
}

enum PairOutcome {
  OUTCOME_UNKNOWN  = 0;
  OUTCOME_APPROVED = 1;  // Existing device approved; credentials present
  OUTCOME_REJECTED = 2;  // Existing device declined the pairing
  OUTCOME_TIMEOUT  = 3;  // Pair session expired before approval
}

message PairCredentials {
  string device_id = 1;
  string account_id = 2;
  string bearer_token = 3;  // For the new device's first THello
  bytes  resume_token = 4;
}
```

### Pairing security notes

- The QR payload contains a bootstrap credential that allows
  *only* a `TPair(CLAIM)` against the named pair_session_id. It is
  not a general auth token.
- The pair session expires after 60 seconds. If the new device
  doesn't claim in time, the existing device must restart pairing.
- Approval requires a signature from an existing-device key, not
  just a UI tap. This prevents a hub compromise from completing
  pairings without user consent on a trusted device.
- The new device's public key is the trust anchor for future E2E
  message encryption. The fingerprint shown on the existing device
  during APPROVE is the user's last line of defence against a
  hub-mediated attacker.

The E2E key management story is detailed in a sibling document
(`docs/encryption.md`, not yet drafted; v1 deliverable).

---

## Error responses

Any T-message may be answered with `RError` instead of its matching
R-message. The tag echoes the request's tag.

```protobuf
message RError {
  // Stable error code. Add new codes by appending; never reuse a
  // retired code's number.
  ErrorCode code = 1;

  // Human-readable description. Not for programmatic use; intended
  // for logs and developer debugging.
  string message = 2;

  // Optional structured details. Schema varies by code.
  bytes details = 3;
}

enum ErrorCode {
  ERROR_UNKNOWN              = 0;

  // Session lifecycle
  VERSION_INCOMPATIBLE       = 1;
  AUTH_INVALID               = 2;
  AUTH_EXPIRED               = 3;
  HELLO_REQUIRED             = 4;  // T-message sent before THello
  ALREADY_HELLOED            = 5;  // Second THello on the same connection

  // Protocol
  TAG_IN_USE                 = 10;
  MALFORMED_ENVELOPE         = 11;
  UNKNOWN_MESSAGE_TYPE       = 12;
  ENVELOPE_TOO_LARGE         = 13;

  // Topics
  TOPIC_INVALID              = 20;
  TOPIC_UNAUTHORIZED         = 21;
  SUBSCRIPTION_NOT_FOUND     = 22;
  PUBLISH_RATE_LIMITED       = 23;
  SUBSCRIBE_RATE_LIMITED     = 24;

  // Catch-up
  CATCHUP_HORIZON_EXCEEDED   = 30;

  // Pairing
  PAIR_SESSION_NOT_FOUND     = 40;
  PAIR_SESSION_EXPIRED       = 41;
  PAIR_SIGNATURE_INVALID     = 42;

  // Generic
  INTERNAL                   = 90;  // Hub-side bug; report to ops
  TEMPORARILY_UNAVAILABLE    = 91;  // Backoff and retry
  ABUSE_DETECTED             = 92;  // Suspicious pattern; connection closing
}
```

`RError` never replaces a D-message. D-messages are unsolicited;
errors flow only in response to T-messages.

---

## Rate limits and abuse

v1 enforces per-connection and per-account limits. Hitting a limit
returns `RError(code=PUBLISH_RATE_LIMITED)` or
`RError(code=SUBSCRIBE_RATE_LIMITED)`; suspicious patterns return
`RError(code=ABUSE_DETECTED)` and close the connection.

Initial v1 limits (subject to public-beta tuning):

| Limit | Value | Scope |
|---|---|---|
| TPublish | 10/sec, 600/hour | Per connection |
| TPublish | 50/sec, 6000/hour | Per account (all devices) |
| TSubscribe | 50 active | Per connection |
| TSubscribe | 200 active | Per account |
| Payload size | 64 KiB | Per TPublish (larger payloads go out-of-band via WebRTC or Bitchain, v1.5+) |
| Connection rate | 5/minute | Per account from new IPs |
| Pair sessions | 3 concurrent | Per account |

Abuse heuristics live server-side and look only at metadata
(message frequency, recipient graph, connection patterns). The hub
**does not** inspect payload contents, in keeping with the E2E
stance. See `agents/projects/quickring.md` for the locked rationale.

---

## Versioning

- Major version is part of the subprotocol string
  (`quickring.v1`, `quickring.v2`, etc.) and the WebSocket
  endpoint path (`/v1`, `/v2`).
- Within a major version, the protocol is backward-compatible.
  New message types are additive; new fields on existing messages
  must be optional.
- Breaking changes require a major version bump. The hub supports
  the previous major version for at least **90 days** after a new
  major version goes live, to give SDKs and third-party agents
  time to migrate.
- This v1 spec is pre-public-beta. Until v1.0.0 ships publicly,
  the protocol may break without notice. Once public beta opens,
  all changes follow the rules above.

---

## What's deliberately not in v1

These are planned for later phases per the project roadmap. They
do not appear in this version of the protocol.

- **File transfer** (`v1.5`). Two complementary mechanisms, both
  keep file bytes off the hub:
  1. **P2P via WebRTC** — for ad-hoc transfers between two online
     agents. The hub carries signaling envelopes via the existing
     publish/subscribe mechanism (`payload_type = "signal"`) but
     never the file bytes.
  2. **Bitchain manifest references** — for content-addressed
     transfers, especially when the sender may go offline before
     the receiver picks up, or when the same content is fanned out
     to many devices. A `TPublish` carries `payload_type =
     "bitchain_ref"` and a payload containing the manifest URI
     (`bitchain://manifest/<hash>`). The receiving agent resolves
     the manifest via Bitchain's own retrieval path (local cache,
     S3, a Refraction instance, or peer Quickring agents acting as
     Bitchain stores). The hub sees only the reference, never the
     blocks. Natural fit for firmware drops to Thunderhead boxes,
     shared media collections, and any content the user already
     has in their Bitchain store.

  Choice between the two is an application-layer decision: WebRTC
  for interactive "send this now" flows, Bitchain refs for
  durable / fanout / store-and-forward shapes. The wire protocol
  treats both uniformly as opaque payloads with a type hint.
- **Teams / multi-account topics** (`v2.5`). The `team/` topic
  prefix is reserved but unused.
- **Federation** (`v3+`). v1 is hub-anchored. No `@user@otherhub`
  identity, no inter-hub message routing.
- **Server-side search, moderation, or content inspection**. Out
  of scope at the protocol level; locked decision in the project
  brief.

---

## Open questions

Captured for resolution before v0 implementation begins.

**Resolved during the Developer review (kept here for the record):**

- ~~**Auth methods in `THello`.**~~ **Resolved:** `THello.auth` is
  a `oneof`, reserved from day one so additional methods are
  additive later, but v1 implements only the `bearer_token` arm.
  Gets the wire-compatibility safety without the v1 scope.
- ~~**`payload_type` enum vs. free-form string.**~~ **Resolved:**
  free-form string. `payload_type` is an application-layer hint the
  hub never interprets, and the value space is owned by apps, not
  the protocol — so locking it into the wire proto would force a
  protocol release every time an app invents a message kind. The
  Bitchain-ref addition is the proof case.
- ~~**Pairing: two responses to one tag.**~~ **Resolved:**
  `TPair(CLAIM)` now gets an immediate terminal `RPairClaimed`, and
  approval/rejection/timeout arrives as a separate `DPairResult`
  push. No tag ever receives two responses.

**Still open:**

1. **Should `TPublish` have a `delivery_receipt_requested` flag**
   that asks the hub to push a `DDelivered` (separate from
   `DDeliver`) when each recipient device acknowledges? Current
   draft: no, keep the protocol simple; receipts are an
   application-layer concern.
2. **Should `subscription_id`s be globally unique or
   per-connection?** Current draft: per-connection (server-assigned
   strings, opaque to client, scope = this connection).
3. **Heartbeat cadence** — 30 seconds chosen somewhat arbitrarily.
   Should be load-tested before public beta to balance presence
   accuracy against hub CPU cost.
4. **`DPairResult` delivery when the new device is briefly
   disconnected.** The new device holds an open connection while
   waiting for approval, so in the common case `DPairResult` pushes
   straight down it. But if that connection drops between
   `RPairClaimed` and approval, the push has nowhere to go. Options:
   the new device polls a `TPair(step=POLL)` on reconnect, or the
   pairing bootstrap connection supports the normal resume
   mechanism. Needs a decision before pairing is implemented (v1,
   not v0), but doesn't affect the v0 plaintext demo.

---

## References

- **9P protocol** (Plan 9 from Bell Labs) — the foundational
  influence. http://man.cat-v.org/plan_9/5/intro
- **XMPP** — wisdom borrowed without adopting the protocol:
  - XEP-0198 Stream Management (reconnect / resume / ack semantics)
  - XEP-0280 Message Carbons (multi-device fanout)
  - XEP-0313 MAM (offline catch-up shape)
  - XEP-0384 OMEMO (E2E key management; feeds the encryption.md spec)
- **Project brief:** `agents/projects/quickring.md` in
  `lockamy-studios/agents`. Locked decisions, mandate, phasing.
- **Bitchain:** content-addressed binary versioning. Manifest
  references travel as Quickring payloads; blocks resolve via
  Bitchain's own retrieval path. Repo: `dlockamy/bitchain`.
- **Lockamy Studios developer agent:** `agents/developer.md` —
  stack conventions, error-handling discipline.

---

*Last updated: 2026-06-06 (v0 shipped — tag `v0`). Maintained by the
Messaging Architect agent. Developer review pass complete; the canonical
`.proto` is published at `../proto/quickring/v1/quickring.proto` and the v0
subset is implemented end to end (hub QR-3..6, Fabric Kit QR-7, agent
QR-8/9 — epic QR-1 closed). Ownership reframed per ADR-0002: this standard
owns both layers; message-kit and Fabric Kit are conforming implementations.*
