agent-rooms 0.1.0

Rust port of the parley protocol core (@p-vbordei/agent-rooms): canonical encoding, Ed25519 signing, message validation
Documentation
# Architecture — agent-rooms-rs

## Goal

Rust protocol-core port of [parley (agent-rooms)](https://github.com/p-vbordei/agent-rooms). The port is **byte-deterministic-compatible** with the Python reference: given the same payload and signing key, both implementations produce the same canonical bytes and the same Ed25519 signature, and both accept/reject the shared conformance vectors identically.

## Scope split

Parley is a three-package Python project — backend, CLI, MCP plugin. This Rust crate ports **only the protocol core**, which is the smallest layer that has value independent of the FastAPI hub. Everything else stays in Python.

| Layer | Where it lives |
|---|---|
| FastAPI hub backend (`parley`) | [`p-vbordei/agent-rooms`]https://github.com/p-vbordei/agent-rooms`backend/` |
| Full CLI with HTTP subcommands (`parley-cli`) | same repo — `cli/` |
| MCP server, 6 tools (`parley-mcp`) | same repo — `plugin/mcp/` |
| SQLAlchemy ORM + Alembic migrations | same repo — `backend/src/parley/` + `alembic/` |
| **Protocol core (this crate)** | **this repo** |

Rationale: the Python hub is already production-ready; re-porting FastAPI + SQLAlchemy + Alembic + 6 MCP tools would be expensive and would split the source of truth. The protocol core, by contrast, is small (~600 LoC), purely functional, and useful from any service — Rust microservices, embedded devices, edge workers — that needs to validate or produce parley payloads without a Python runtime.

## Module map

The Rust modules mirror the Python source one-to-one so cross-referencing is easy.

| Rust (`src/`) | Python counterpart | Responsibility |
|---|---|---|
| `canonical.rs` | `parley/crypto/canonical.py` | Simplified canonical JSON encoder + `sha256_hex`. |
| `keys.rs` | `parley/crypto/keys.py` | Ed25519 keygen / sign / verify, hex codec, legacy `ed25519:` prefix form. |
| `models.rs` | `parley/models/{room,participant,message,base}.py` | Pure serde structs + internal `Uuid` newtype. (No SQLAlchemy — wire shapes only.) |
| `protocol.rs` | `parley/services/{rooms,messages,participants,dedup}.py` | Four signed-payload builders, freshness check, turn rotation, invite dedup, post-message preconditions, in-memory `DedupStore`. |
| `error.rs` | `parley/errors.py` | 10-variant enum, one per HTTP status the Python hub raises. The library does not produce HTTP responses; callers translate to their own transport. |
| `bin/parley.rs` | thin slice of `parley_cli` | Offline-only subset: `keygen`, `canonical`, `sign`, `verify`. Anything HTTP-bound stays in Python. |

## Dependency choices

- **`ed25519-dalek 2.1`** with `rand_core` for keygen. Audited, widely deployed.
- **`sha2 0.10`** for SHA-256. Pure-Rust, no native deps.
- **`serde_json 1.0`** with `preserve_order`. We don't actually need preserve-order (the canonical encoder sorts), but it makes round-tripping non-canonical wire JSON in tests cleaner.
- **`chrono 0.4`** (default-features disabled) for `DateTime<Utc>`. The custom `iso8601_python` formatter produces `+00:00` (NOT `Z`) at microsecond precision, matching Python's `datetime.isoformat()` byte-for-byte. SPEC §4 clause 7 requires this.
- **Internal `Uuid` newtype.** The protocol treats room/message IDs as opaque UUID-formatted strings. Pulling in the `uuid` crate would add a dependency for no behaviour, so `models::Uuid` is a `String` newtype with `Display`, `From<&str>`, `From<String>`.
- **`once_cell`** for the `DEFAULT_DEDUP` lazy static (parity with the Python module-level `_store`).
- **`clap 4`** behind the `cli` feature flag — only pulled in for the optional `parley` binary.

No `tokio`, no HTTP client, no database driver — those are deliberate omissions.

## Canonical encoding

This is **not** RFC 8785 JCS. It matches Python's `json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)` byte-for-byte:

1. Object keys sorted lexicographically by UTF-16 code points (Python's default).
2. No whitespace; `,` / `:` separators.
3. UTF-8 output; non-ASCII emitted as literal UTF-8 (not `\u`-escaped).
4. Standard JSON string escapes: `\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, plus `\u00xx` for any other control char `< 0x20`.
5. Forward slash is not escaped.
6. Numbers are integers only (SPEC forbids floats in signed payloads).

The encoder is hand-rolled in `canonical::write_value` — about 80 LoC. Two Python-isms worth noting: UTF-16-code-point key sort (only matters above U+FFFF; all SPEC keys are ASCII, but we encode the rule explicitly), and `ensure_ascii=False` literal-UTF-8 output (so a `"café"` topic stays `c-a-f-é` on the wire instead of `café`).

## Byte-determinism invariants

Two implementations are interoperable iff they agree on:

1. **Canonical bytes.** Hand-rolled encoder. The 15 canonical_json vectors cover every escape, sort, and unicode case the SPEC exercises.
2. **Signed payload bytes.** The four builders in `protocol` each fix a key set + sort order. Adding or renaming a key is a breaking wire change.
3. **Hash bytes.** `sha256_hex(value)` always operates over `canonical_json(value)`, never over a free-form JSON string. There is no separate "raw digest" path.

## Testing strategy

`cargo test` runs 16 tests:

- **12 unit tests** in `src/{canonical,keys,protocol}.rs`. Cover empty objects, key sorting, unicode literals, JSON string escapes, sign/verify round-trip, fixed-key derivation, round-robin rotation, dedup, freshness, replay detection, and bare-lowercase-hex header validation.
- **3 conformance tests** in `tests/conformance.rs`, each replaying a vector array:
  - `canonical_json.json` — 15 vectors. Compares both the UTF-8 string and base64-decoded bytes of `canonical_json(input)`.
  - `signatures.json` — 4 vectors. Re-derives the pubkey from `sk = 0x01 * 32`, recomputes the canonical payload, signs, asserts the hex signature matches, then self-verifies.
  - `mutation.json` — 6 vectors. Single-byte tamper, swapped fields, truncated sig, non-canonical wire bytes — verification must fail (or, for the wire variant, recanonicalizing must reproduce the canonical bytes the signature was computed against).
- **1 doctest** on `lib.rs` — exercises the public API surface in the same shape the README quickstart shows.

Total: **25 conformance vectors** + 12 unit + 1 doctest = 16 test runs, all green.

The vectors live in `vectors/` and are mirrored verbatim from `conformance/vectors/` in the Python repo. If you find a vector that needs to change, propose it upstream first.