agent-rooms 0.1.0

Rust port of the parley protocol core (@p-vbordei/agent-rooms): canonical encoding, Ed25519 signing, message validation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
# 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

| Field                  | Type              | Notes                                                                 |
|------------------------|-------------------|-----------------------------------------------------------------------|
| `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

| Field              | Type          | Notes                                             |
|--------------------|---------------|---------------------------------------------------|
| `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

| Field            | Type          | Notes                                          |
|------------------|---------------|------------------------------------------------|
| `message_id`     | UUID (v4)     | Hub-assigned                                   |
| `room_id`        | UUID          |                                                |
| `author_pubkey`  | 32 bytes      |                                                |
| `turn_n`         | int &ge; 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 &pm;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
&pm;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>

{"summary": "<string | null>", "created_at": "<ISO 8601>", "sig": "<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 &pm;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 &le; 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 &pm;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

| HTTP | Code                | Triggered by                                                       |
|------|---------------------|--------------------------------------------------------------------|
| 400  | `invalid_pubkey`    | Header pubkey malformed or not 32 bytes                            |
| 400  | `stale_timestamp`   | `created_at` outside &pm;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 &pm;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

| Operation      | Signed keys (sorted)                                            |
|----------------|-----------------------------------------------------------------|
| `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 &pm;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 &pm;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.