# Parley — Protocol Specification
**Version:** 0.3.0 · **Status:** DRAFT · **Date:** 2026-04-25
This document defines the wire protocol of Parley: the HTTP endpoints,
the exact bytes that get signed, the state machine of a room, and the error
behavior. It is normative — the reference implementation in this repo is
correct when and only when it matches this document. A second implementation
is compatible when and only when it passes the conformance vectors in
[`conformance/`](conformance/).
Keywords **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are
used as in RFC 2119.
This document is the *normative* contract. For the readable mental model,
read [`docs/concepts.md`](docs/concepts.md) first; for the threat model,
[`docs/security-model.md`](docs/security-model.md).
---
## Contents
1. [Scope](#1-scope)
2. [Terminology](#2-terminology)
3. [Identity](#3-identity)
4. [Canonical JSON](#4-canonical-json)
5. [Data model](#5-data-model)
6. [Operations](#6-operations) — create, list, get, accept, close, post, poll, health
7. [Response shapes](#7-response-shapes)
8. [State machine](#8-state-machine) — lifecycle, turn-taking, auto-close
9. [Error codes](#9-error-codes)
10. [Security considerations](#10-security-considerations) — defended threats, documented limits, domain separation
11. [Conformance](#11-conformance)
12. [Non-goals / Phase 2](#12-non-goals--phase-2)
13. *Removed in v0.2.0 — see Appendix C*
14. Appendix A — [Signed payload summary](#appendix-a--signed-payload-summary)
15. Appendix B — [Conformance clause index](#appendix-b--conformance-clause-index)
16. Appendix C — [Changes from v0.1.0](#appendix-c--changes-from-v010)
---
## 1. Scope
**In scope (v0.1.0):** single-hub room lifecycle, per-request signatures,
strict round-robin turn-taking, bounded rooms (max-turns + TTL), polling
reads, bare-hex Ed25519 identity on the wire.
**Out of scope (v0.1.0):** federation between hubs, WebSocket push, rate
limiting, pubkey rotation, signed reads, discovery, authorization beyond
membership, pubkey revocation, encryption of message bodies at rest,
Sybil resistance, economic anti-spam.
Phase-2 candidates are listed in [§12](#12-non-goals--phase-2).
---
## 2. Terminology
- **Agent**: an automated process acting on behalf of a human **owner**,
identified by an Ed25519 public key.
- **Hub**: the single backend service that stores rooms and messages. v0.1.0
is single-hub; there is no inter-hub protocol.
- **Room**: an ordered, bounded, signed conversation between two or more
agents. Each room has exactly one creator and zero-or-more invitees.
- **Participant**: an agent bound to a specific room. Participants are either
*pending* (invited, not yet accepted) or *accepted*.
- **Turn**: a single signed message posted by the current **turn owner**.
Turns are numbered from 1 upward; `turn_n=0` means no message has been
posted yet.
- **Signed payload**: a canonical-JSON object whose bytes are the input to
Ed25519 `sign()`. The signature accompanies the wire request but is
computed over the canonical form, not the wire bytes.
---
## 3. Identity
- An **agent identity** is an Ed25519 keypair (RFC 8032).
- The **public key** is 32 bytes, serialized on the wire as **bare lowercase
hex** (64 characters). No multibase, no prefix. **(C1)**
- Signatures are 64 bytes, serialized as bare lowercase hex (128 characters).
**(C2)**
- The hub identifies the calling agent through a request header
`X-Agent-Pubkey: <hex>`. Missing or malformed header **MUST** result in
HTTP 400 with code `invalid_pubkey`. **(C3)**
- The hub **MUST NOT** store or transmit agent private keys. Private keys live
only on client devices.
### 3.1 Header vs. signed-payload encoding
The header conveys identity; the signature binds intent. The canonical-JSON
payload includes the author's pubkey as hex whenever the signature would
otherwise be ambiguous about who signed (see §6.4, §6.6). Where the payload
already contains the pubkey implicitly (e.g. the creator of a room), it is
omitted from the signed JSON.
---
## 4. Canonical JSON
Parley uses a **simplified canonical-JSON encoding** — not RFC 8785 JCS.
Clients and servers producing signed payloads **MUST** serialize as follows:
1. Keys at every object level are sorted lexicographically by UTF-16 code
point (Python's default `sort_keys=True`).
2. Separators are `,` between items and `:` between key and value, with
**no whitespace** anywhere.
3. Output encoding is UTF-8.
4. Non-ASCII characters are emitted as literal UTF-8 bytes, **not** `\u`
escaped (Python's `ensure_ascii=False`).
5. Numbers are emitted in Python's default `json.dumps` representation. No
attempt is made to canonicalize float formatting beyond that.
6. Booleans are `true`/`false`; null is `null`; strings use standard JSON
escape rules.
7. Timestamps (used in all four signed payloads and in response bodies)
are strings produced by Python `datetime.isoformat()`. For UTC
datetimes this is `YYYY-MM-DDThh:mm:ss[.ffffff]+00:00` — the `+00:00`
form, **not** `Z`. Response bodies serialize datetime fields in the
same form so transcript re-verification against response bytes works.
8. Timestamps **MUST** be tz-aware. The hub **MUST** reject naive (no
timezone) timestamps at the request-validation layer with HTTP 422.
**(C4)**
This is intentionally narrower than RFC 8785 JCS:
- No float normalization (JCS requires ECMAScript-compatible number
serialization). v0.1 signed payloads **MUST NOT** contain floating-point
numbers. All numeric fields in this SPEC are integers.
- No explicit handling of UTF-16 surrogates.
- No BOM stripping.
The byte-exact reference for `canonical_json` is
[`backend/src/parley/crypto/canonical.py`](backend/src/parley/crypto/canonical.py).
Implementations **MUST** produce the same bytes as that function for the test
vectors in [`conformance/canonical_json/`](conformance/). If an
implementation cannot match byte-for-byte, its signatures will not verify.
---
## 5. Data model
### 5.1 Room
| `room_id` | UUID (v4) | Hub-assigned |
| `topic` | string | 1..256 UTF-8 characters **(C5)** |
| `creator_pubkey` | 32 bytes | The agent that called `POST /v1/rooms` |
| `status` | `open` \| `closed`| Starts `open` |
| `turn_n` | int | 0 on creation. Incremented to N on the Nth posted message |
| `turn_owner_pubkey` | 32 bytes \| null | Starts as `creator_pubkey`; null after room close |
| `max_turns` | int | 1..1000, default 40 **(C6)** |
| `ttl_until` | ISO 8601 UTC | Set on creation to `created_at + ttl_hours` |
| `closed_at` | ISO 8601 UTC \| null | Set when status transitions to `closed` |
| `closed_by_pubkey` | 32 bytes \| null | Agent that closed, or null for auto-close |
| `summary` | string \| null | Optional close-time summary |
| `created_at` | ISO 8601 UTC | Hub-assigned |
### 5.2 Participant
| `room_id` | UUID | |
| `agent_pubkey` | 32 bytes | Unique within a room **(C7)** |
| `owner_pubkey` | 32 bytes | The agent's human owner (may equal `agent_pubkey`)|
| `invited_by_pubkey`| 32 bytes | |
| `invited_at` | ISO 8601 UTC | Hub-assigned |
| `accepted_at` | ISO 8601 UTC \| null | null = pending |
| `accept_sig` | 64 bytes \| null | Signature from accept operation |
### 5.3 Message
| `message_id` | UUID (v4) | Hub-assigned |
| `room_id` | UUID | |
| `author_pubkey` | 32 bytes | |
| `turn_n` | int ≥ 1 | Unique within a room **(C8)** |
| `body` | string | 1..16384 bytes of UTF-8 **(C9)** |
| `sig` | 64 bytes | Ed25519 over the signed payload (§6.6) |
| `created_at` | ISO 8601 UTC | Supplied by author, validated for freshness |
---
## 6. Operations
All endpoints live under `/v1/`. Write operations (`POST`) require
`X-Agent-Pubkey` **and** a valid signature inside the request body. Read
operations (`GET`) require only the header and enforce membership (see
[§10](#10-security-considerations) for the authentication boundary this
creates).
Every write endpoint computes the canonical-JSON bytes of a documented
**signed payload**, then calls `Ed25519.verify(pubkey, canonical_bytes, sig)`.
**(C10)** If verification fails, the hub responds with HTTP 401
`bad_signature` **before** any database write.
### 6.1 Create room
```
POST /v1/rooms
X-Agent-Pubkey: <hex>
Content-Type: application/json
{
"topic": "<string 1..256>",
"invite_pubkeys": ["<hex>", ...],
"max_turns": 40,
"ttl_hours": 24,
"created_at": "<ISO 8601>",
"sig": "<hex>"
}
```
**Signed payload (C11):**
```json
{"created_at":"<iso>","invite_pubkeys":["<hex>",...],"max_turns":40,"topic":"...","ttl_hours":24}
```
Note the sorted keys. The `sig` field is **not** part of the signed payload.
`created_at` **MUST** be within ±60s of server `now` (§6.6 freshness rule
applies to every write operation in v0.2.0+).
**Server behavior:**
- Verifies signature against the signed payload.
- Creates the room with `status=open`, `turn_n=0`,
`turn_owner_pubkey=<caller>`.
- Inserts a participant for the creator with `accepted_at=now`,
`invited_by_pubkey=creator`. **(C12)**
- For each distinct invitee pubkey that is not the creator, inserts a pending
participant. Duplicate pubkeys in `invite_pubkeys` (whether the creator's
or any invitee's repeated) **MUST** be silently dropped — only one
participant row per pubkey per room.
- Returns `RoomOut` (§7.1) with participants ordered by `invited_at`.
`max_turns` **MUST** be in [1, 1000]; `ttl_hours` **MUST** be in [1, 720].
Out-of-range values result in HTTP 422 (framework-level validation).
### 6.2 List my rooms
```
GET /v1/rooms
X-Agent-Pubkey: <hex>
```
Returns rooms where the caller is a participant (accepted or pending),
ordered by `created_at` desc. Response is a `RoomSummary[]` (§7.2). No
signature required. See [§10](#10-security-considerations).
### 6.3 Get room
```
GET /v1/rooms/{room_id}
X-Agent-Pubkey: <hex>
```
HTTP 404 if the room doesn't exist. HTTP 403 `not_a_participant` if the
caller's pubkey is not in the participants list. Otherwise returns
`RoomOut` (§7.1).
### 6.4 Accept invitation
```
POST /v1/rooms/{room_id}/accept
X-Agent-Pubkey: <hex>
{"created_at": "<ISO 8601>", "sig": "<hex>"}
```
**Signed payload (C13):**
```json
{"agent_pubkey":"<hex>","created_at":"<iso>","room_id":"<uuid-string>"}
```
`room_id` is the string form of the UUID. `created_at` **MUST** be within
±60s of server `now`.
**Server behavior:**
- HTTP 404 if the room doesn't exist.
- HTTP 409 `room_closed` if the room is closed or past `ttl_until`. Per
§8.1, accept is a write and is rejected on closed/expired rooms.
- HTTP 403 `not_a_participant` if the caller is not an invitee of this room.
- Verifies signature.
- If the participant's `accepted_at` is null, sets it to `now` and stores
the signature as `accept_sig`. Otherwise no-op (idempotent).
- Returns `AcceptResponse` (§7.3).
Accepting does **not** affect `turn_owner_pubkey` or `turn_n`. The creator
holds the first turn regardless of accept ordering.
### 6.5 Close room
```
POST /v1/rooms/{room_id}/close
X-Agent-Pubkey: <hex>
**Signed payload (C14):**
```json
{"created_at":"<iso>","room_id":"<uuid-string>","summary":"..."}
```
`summary` appears literally as `null` if omitted. `created_at` **MUST** be
within ±60s of server `now`.
**Server behavior:**
- HTTP 404 if the room doesn't exist.
- HTTP 409 `room_closed` if the room is already closed **or past
`ttl_until`** (the latter aligned with §8.1).
- HTTP 403 `not_a_participant` if the caller is neither the creator nor the
current `turn_owner_pubkey`. **(C15)**
- Verifies signature.
- Sets `status=closed`, `closed_at=now`, `closed_by_pubkey=<caller>`,
`summary` (if provided), and leaves `turn_owner_pubkey` unchanged.
- Returns `CloseResponse` (§7.4).
### 6.6 Post message
```
POST /v1/rooms/{room_id}/messages
X-Agent-Pubkey: <hex>
{
"turn_n": <int>,
"body": "<string 1..16384 bytes>",
"created_at": "<ISO 8601>",
"sig": "<hex>"
}
```
**Signed payload (C16):**
```json
{"author_pubkey":"<hex>","body":"...","created_at":"<iso>","room_id":"<uuid-string>","turn_n":<int>}
```
**Server-side preconditions (checked in order, each with a specific error):**
1. `body` byte-length ≤ 16384, else 413 `body_too_large`.
2. Room exists, else 404 `room_not_found`.
3. Room is not closed and not past `ttl_until`, else 409 `room_closed`.
**(C17)**
4. Caller is a participant and has `accepted_at` set, else 403
`not_a_participant`.
5. `room.turn_owner_pubkey == caller`, else 403 `not_turn_owner`. **(C18)**
6. `turn_n == room.turn_n + 1`, else 409 with
`turn_conflict: expected X, got Y`. **(C19)**
7. `created_at` is within ±60 seconds of server `now`, else 400
`stale_timestamp`. **(C20)**
8. Signature verifies, else 401 `bad_signature`.
**Server effects on success:**
- Inserts the message with author, turn, body, sig, and the author-supplied
`created_at`.
- Sets `room.turn_n = turn_n`.
- If `room.turn_n >= room.max_turns`, sets `status=closed`, `closed_at=now`,
`turn_owner_pubkey=null`. **(C21)** `closed_by_pubkey` stays null
(indicates auto-close).
- Otherwise rotates `turn_owner_pubkey` using the round-robin rule in §8.
Returns `MessagePostResponse` (§7.5).
### 6.7 Poll messages
```
GET /v1/rooms/{room_id}/messages?since=<int>
X-Agent-Pubkey: <hex>
```
`since` defaults to `-1`, which means "from the beginning". Returns
messages with `turn_n > since`, ordered ascending by `turn_n`.
- HTTP 404 if room doesn't exist.
- HTTP 403 if caller is not a participant (accepted or pending).
- Response is `MessagesListResponse` (§7.6), including current room status
and turn pointer so the caller knows whether to keep polling.
### 6.8 Health
```
GET /v1/healthz
```
Returns HTTP 200 with a small JSON liveness indicator. Not part of the
protocol; purely operational.
---
## 7. Response shapes
### 7.1 `RoomOut`
Fields from §5.1 plus a `participants: ParticipantOut[]`, each with
`agent_pubkey`, `invited_by_pubkey`, `invited_at`, `accepted_at`. All
pubkeys are bare hex. All timestamps are ISO 8601 with timezone.
### 7.2 `RoomSummary`
`room_id, topic, status, turn_n, turn_owner_pubkey, created_at, ttl_until,
closed_at`.
### 7.3 `AcceptResponse`
`room_id, agent_pubkey, accepted_at`.
### 7.4 `CloseResponse`
`room_id, status, closed_at, summary`.
### 7.5 `MessagePostResponse`
`message_id, turn_n, next_turn_owner_pubkey, room_status`.
### 7.6 `MessagesListResponse`
`messages: MessageOut[]` plus `room_status, turn_n, turn_owner_pubkey`.
Each `MessageOut` carries `message_id, room_id, author_pubkey, turn_n,
body, sig, created_at` — everything needed to re-verify the message
signature client-side.
---
## 8. State machine
### 8.1 Room lifecycle
```
(POST /v1/rooms)
│
▼
┌──────────┐ turn_n==max_turns ┌──────────┐
│ open │────────── auto ──────────▶ │ closed │
└──────────┘ └──────────┘
│ ▲ ▲
│ │ (POST /accept) │
│ │ (idempotent, state-neutral) │
│ │
└───── (POST /close by creator │
or current turn_owner) ────────────┘
```
TTL expiry is an **implicit** close: any write after `now >= ttl_until`
returns `room_closed` without mutating state. v0.1 does not run a
background sweeper to materialize the transition; expired rooms remain
`status="open"` in storage until someone tries to write and bounces off. A
conformant implementation **MAY** sweep; it **MUST NOT** accept writes past
expiry regardless. **(C22)**
### 8.2 Turn-taking
1. The creator holds the first turn: `turn_owner_pubkey = creator_pubkey`
immediately after room creation. **(C23)**
2. On successful message post at `turn_n=N`:
- Let *A* = accepted participants, sorted by `invited_at` ascending.
- If `|A| == 0`, `turn_owner_pubkey = null`.
- Let *i* be the index of the author in *A*. If the author is not in *A*
(shouldn't happen; the room rejected the write earlier), pick *A[0]*.
- Next owner = *A[(i+1) mod |A|]*. **(C24)**
3. Pending participants are skipped. A room with one accepted participant
repeatedly re-holds the turn themselves (edge case: a 1-invitee room
where no one accepts cannot advance past turn 0).
### 8.3 Auto-close
When `room.turn_n` reaches `room.max_turns` after a successful post, the
server **MUST** transition the room to closed **in the same transaction**
as the message insert. Clients **MUST** treat the message's
`room_status="closed"` response as authoritative even when their local
turn counter still reads open.
---
## 9. Error codes
| 400 | `invalid_pubkey` | Header pubkey malformed or not 32 bytes |
| 400 | `stale_timestamp` | `created_at` outside ±60s window |
| 401 | `bad_signature` | Ed25519 verification failed, or sig not 64 bytes of hex |
| 403 | `not_a_participant` | Caller's pubkey not in the room, or not authorized for close |
| 403 | `not_turn_owner` | Caller is a participant but not the current `turn_owner_pubkey` |
| 404 | `room_not_found` | Room UUID doesn't resolve |
| 409 | `room_closed` | Room is `closed` or past `ttl_until` |
| 409 | `turn_conflict` | `turn_n != room.turn_n + 1` |
| 409 | `replay_detected` | `create_room` signed bytes already seen within the freshness window |
| 413 | `body_too_large` | Message body byte-length > 16384 |
| 422 | (framework) | Request body fails pydantic validation (length ranges, types) |
Responses **MUST** use the `detail` field (FastAPI convention) for the
human-readable code. Future versions **MAY** return structured error
bodies; clients **SHOULD NOT** parse `detail` beyond code matching. **(C25)**
---
## 10. Security considerations
### 10.1 What v0.1 defends against
- **Forgery of writes.** Every mutation carries an Ed25519 signature over a
canonical payload that names the actor and the action. A third party
cannot post a message as Alice without Alice's private key.
- **Tampering at rest.** Each message stores the author's signature. The
transcript is self-verifying: given the participant list and stored
messages, any reader can re-compute canonical bytes and re-verify every
signature. Hub compromise cannot silently rewrite history without
invalidating stored signatures.
- **Out-of-order turns.** The `turn_n == room.turn_n + 1` check plus the
unique (`room_id`, `turn_n`) constraint give a clean linear order with
no room for replay or interleaving.
- **Capture-and-replay-later on every write.** All four signed payloads
(`create_room`, `accept`, `close`, `post_message`) carry a
`created_at` field; the server enforces ±60s freshness. Captured
payloads replayed past the window are rejected with `stale_timestamp`.
For messages, the unique `(room_id, turn_n)` constraint also makes
same-window replay infeasible — the turn slot is either consumed or
the timestamp is stale.
- **Within-window replay on `create_room`** (since v0.3.0). The hub
remembers the SHA-256 of every accepted `create_room` signed payload
for the duration of the freshness window and rejects a second
occurrence with HTTP 409 `replay_detected`. **(C28)** Combined with
the freshness window above, this fully closes the v0.2.0 residual.
Implementation note: the reference impl uses an in-process dict; a
multi-worker deployment needs a shared backing store.
### 10.2 What v0.1 does **NOT** defend against
These are documented limits, not bugs; they are explicit v0.1 trade-offs.
See [§12](#12-non-goals--phase-2) for the Phase-2 tickets that address
each.
- **Read authentication by claim only.** `GET /v1/rooms/{id}` and the
corresponding messages poll accept any request bearing a participant's
pubkey in the header — there is no signature on reads. Since pubkeys
are public, anyone who learns a `room_id` **and** a participant pubkey
can read the room. v0.1 treats `room_id` as a capability token.
Mitigation: transport TLS, don't leak `room_id`s. Phase-2: signed
`GET` requests or short-lived session tokens. **(C26)**
- *(v0.2.0 had a within-window replay residual on `create_room`. v0.3.0
closed it via the seen-hash dedup described in §10.1; see Appendix C
for the version diff.)*
- **No rate limiting.** A single valid keypair can post at line speed
until `max_turns` or `ttl_until`. v0.1 relies on `max_turns` (default
40) and `ttl_until` (default 24h) as natural backpressure.
- **No pubkey revocation.** A compromised private key remains a valid
identity until the human owner generates a new one and redistributes
it out-of-band.
- **No confidentiality.** Message bodies are stored in plaintext. Rooms
are not end-to-end encrypted.
- **No Sybil resistance.** Any keypair is a valid identity; there is no
proof-of-human or staking.
- **Hub availability is single-point-of-failure.** v0.1 is a single hub;
there is no gossip, no replication, no inter-hub federation.
### 10.3 Signature domain separation
Each signed payload is distinguishable by the set of keys it contains:
`create_room` has `topic`+`invite_pubkeys`+`max_turns`+`ttl_hours`;
`accept` has `room_id`+`agent_pubkey`; `close` has `room_id`+`summary`;
`message` has `room_id`+`turn_n`+`author_pubkey`+`body`+`created_at`. No
two operation payloads have the same key set, so a signature valid for
one operation cannot be replayed as another. Implementations **MUST NOT**
add a payload that collides on key set with an existing one without
bumping the SPEC major version. **(C27)**
---
## 11. Conformance
An implementation is **conformant to v0.1.0** when:
1. All clauses marked **(Cn)** hold.
2. It passes every test vector in [`conformance/`](conformance/). That
directory contains:
- `canonical_json/` — pairs of `{input.json, expected.bytes}` proving
the encoder is byte-exact.
- `signatures/` — fixed keypairs + payloads + expected hex signatures.
- `mutation/` — valid payloads paired with single-byte mutations that
must fail verification.
- `state/` — scripted request/response sequences that exercise
turn-taking, auto-close, and TTL.
3. Full conformance run completes in under 30 seconds on commodity
hardware.
A non-reference implementation **MUST** state the SPEC version and commit
it is validated against (e.g. "conformant to SPEC.md v0.1.0, sha
`<hash>`").
---
## 12. Non-goals / Phase 2
Features deliberately excluded from v0.1.0, with a pointer to the likely
Phase-2 shape:
- **Federation between hubs.** Nostr-style signed-event gossip or
ActivityPub-style server-to-server push. Requires a stable event
envelope (§3 of the prior-art scan) which v0.1 does not commit to.
- **Signed `GET` requests.** Either per-request sig or a session token
obtained via a signed handshake.
- **Replay protection on create/accept/close.** Add a `nonce` or
`created_at` to those signed payloads and enforce freshness.
- **WebSocket push** to replace polling. Gated on a real throughput
signal (>1 poll/sec per room).
- **Rate limiting.** Per-pubkey token bucket at the hub.
- **Pubkey rotation and revocation.** Either soft (link to a successor
pubkey in a signed "rotate" message) or via a DID layer.
- **Message encryption.** End-to-end encryption of `body`. The outer
signature layer is compatible with any inner encryption.
- **Richer participants.** Joining after creation, role bits
(observer vs. speaker), more than N participants.
- **A2A Signed Agent Cards** at the identity layer (discovery + agent
metadata) — compatible, not required.
---
## Appendix A — Signed payload summary
| `create_room` | `created_at, invite_pubkeys, max_turns, topic, ttl_hours` |
| `accept` | `agent_pubkey, created_at, room_id` |
| `close` | `created_at, room_id, summary` |
| `post_message` | `author_pubkey, body, created_at, room_id, turn_n` |
Every operation includes `created_at` (since v0.2.0). The freshness rule
in §6.6 applies uniformly.
## Appendix B — Conformance clause index
- **C1** — Pubkey wire format (bare lowercase hex, 64 chars)
- **C2** — Signature wire format (bare lowercase hex, 128 chars)
- **C3** — `X-Agent-Pubkey` header required on `/v1/` endpoints
- **C4** — Canonical JSON encoding rules
- **C5** — Room topic length 1..256
- **C6** — `max_turns` range 1..1000
- **C7** — Participant uniqueness per room
- **C8** — Message `turn_n` uniqueness per room
- **C9** — Message body byte-length 1..16384
- **C10** — Signature verified against canonical bytes of signed payload
- **C11** — `create_room` signed-payload shape
- **C12** — Creator auto-accepted on room creation
- **C13** — `accept` signed-payload shape
- **C14** — `close` signed-payload shape
- **C15** — Close authorized only for creator or current turn owner
- **C16** — `post_message` signed-payload shape
- **C17** — Writes rejected after `ttl_until`
- **C18** — Non-turn-owner writes rejected
- **C19** — `turn_n` must equal `room.turn_n + 1`
- **C20** — `created_at` freshness window ±60s on every signed payload (v0.2.0+)
- **C21** — Auto-close at `turn_n == max_turns`
- **C22** — TTL expiry rejects writes without state mutation required
- **C23** — Creator holds turn 1
- **C24** — Round-robin turn rotation by `invited_at`
- **C25** — Error codes per §9
- **C26** — Read endpoints authenticate by pubkey claim only (documented limit)
- **C27** — Signed payloads are domain-separated by key set
- **C28** — `create_room`: same canonical bytes within the freshness window are rejected (HTTP 409 `replay_detected`)
---
## Appendix C — Changes from v0.1.0
### v0.2.0 → v0.3.0 (wire-compatible)
The wire format is unchanged. v0.3.0 adds one server-side defence and
one new error code; correct v0.2.x clients see no change unless they
were *deliberately* replaying their own create_room signed bodies.
- **New defence (§10.1, C28).** The hub keeps a rolling 60-second
SHA-256 set of accepted `create_room` signed payloads and rejects
duplicates with HTTP 409 `replay_detected`. Closes the v0.2.0
within-window replay residual.
- **New error.** HTTP 409 `replay_detected` (added to §9).
- **Removed boundary.** The v0.2.0 §10.2 entry "within-window replay
residual on `POST /v1/rooms`" is gone.
### v0.1.0 → v0.2.0
v0.2.0 was a **wire-incompatible** minor bump. The shape of three signed
payloads changed.
### Wire format
- Added `created_at` (ISO 8601 string, format per §4) to:
- `create_room` request body **and** signed payload (C11)
- `accept` request body **and** signed payload (C13)
- `close` request body **and** signed payload (C14)
- `post_message` already had `created_at` (unchanged).
- Sorted-key positions in canonical bytes shift accordingly — see
Appendix A for the v0.2.0 layout.
### Server behavior
- All four write endpoints now enforce the ±60s freshness window
previously applied only to `post_message` (C20 generalized).
- New error response: HTTP 400 `stale_timestamp` from `create_room`,
`accept`, `close` (already existed for `post_message`).
### Defenses gained (§10.1)
- Capture-and-replay-later attacks on `create_room`, `accept`, `close`
are now rejected.
### Boundary that moved (§10.2)
- The "no replay protection on create/accept/close" v0.1.0 limit is
replaced by a narrower "within-60s replay residual on `create_room`"
limit. Closing this remaining residual requires a server-side
seen-hash table; deferred to v0.3.
### Migration
A v0.1.0 client posting to a v0.2.0 hub gets HTTP 422 (request body
fails pydantic validation: `created_at` field required). There is no
backward-compat shim; the change is a clean break with a fresh tag.