---
id: PRD-0002
title: "M1 — Wire Protocol, Identity/Keystore, Embedded Store"
status: done
owner: "Aaron Roney"
created: 2026-07-01
updated: 2026-07-01

depends_on:
- PRD-0001

principles:
- "Wire format is versioned and E2E-ready from day one (DESIGN §13): reserve the encrypted-payload envelope + key-id now."
- "Identity keys never leave the machine and are secrecy-wrapped in memory (DESIGN §12)."
- "SurrealDB is embedded via the bare official SDK behind a thin per-table repository (DESIGN §15) — no third-party ORM."

references:
- name: "Conclave design (protocol §13, identity §5, persistence §15, threat model §12)"
  url: "docs/DESIGN.md"
- name: "ratrod protocol/base/security modules (bincode frames, secrecy, key handling)"
  url: "https://github.com/twitchax/ratrod"

acceptance_tests:
- id: uat-001
  name: "Protocol frames round-trip (encode/decode) including version field and the opaque E2E envelope"
  command: cargo nextest run -E 'test(/protocol/)'
  uat_status: verified
- id: uat-002
  name: "Identity: generate keypair, sign a server challenge, verify; private key is secrecy-wrapped and never logged"
  command: cargo nextest run -E 'test(/identity/)'
  uat_status: verified
- id: uat-003
  name: "Store repo CRUD with uniqueness constraints (user.username, machine.pubkey, machine.name-within-user, channel.name)"
  command: cargo nextest run -E 'test(/store/)'
  uat_status: verified

tasks:
- id: T-001
  title: "Domain types in base: participant path, PermissionLevel, Visibility"
  priority: 1
  status: done
  notes: "Path { user, machine, session } with parse/display; PermissionLevel { Mute, Notify, Converse, Act } ordered; Visibility { Public, Unlisted, Private }. Serde-derive where used on the wire."
- id: T-002
  title: "Wire protocol: versioned control + data frames with E2E-ready envelope"
  priority: 1
  status: done
  notes: "ProtocolMessage enum (control: Hello/version-negotiate, Challenge, Auth, Register, Join, Leave, Who, admin ops; data: ChannelMsg, Whisper, Presence). Protocol-version field negotiated in Hello. Data frame reserves { ciphertext-envelope: Bytes, key_id: Option } for v2 E2E. Choose ser format (bincode v2 with explicit config, per ratrod). Send/Receive traits over an async stream."
- id: T-003
  title: "Typed boundary errors (thiserror): ProtocolError, AuthError, AclError"
  priority: 2
  status: done
  notes: "These serialize to wire error frames and are matched on by server/bridge. anyhow Res/Void remains for app glue."
- id: T-004
  title: "Identity + keystore"
  priority: 1
  status: done
  notes: "Ed25519 keypair gen + sign/verify (decide ring vs ed25519-dalek — note the choice). Private key SecretBox-wrapped (secrecy). Keyfile + config.toml under ~/.config/conclave/: identity, known servers, per-server registrations (username+machine name), permission config (default level + per-(server,channel)/whisper overrides)."
- id: T-005
  title: "Embedded SurrealDB schema + thin repository"
  priority: 1
  status: done
  notes: "surrealdb crate w/ embedded KV backend (SurrealKV or RocksDB). Tables user/machine/channel/invite per DESIGN §15 with UNIQUE constraints. Repository module: typed serde structs + minimal SurrealQL; no ORM. Presence/subscriptions are NOT persisted."
---

# Summary

Build the shared substrate the server and bridge both depend on: the versioned, E2E-ready wire
protocol; the machine identity/keystore (Ed25519 + `secrecy` + local config); and the embedded
SurrealDB schema behind a thin repository.

# Problem

The server (M2) and bridge (M3) can't be built until their common contract — frames, identity,
and durable storage — exists. Getting the wire format's version field and encrypted-payload
envelope right *now* is what keeps v2 E2E additive rather than a breaking change.

# Goals

1. A versioned frame schema with an opaque encrypted-payload envelope reserved for E2E.
2. Machine identity: Ed25519 keygen/sign/verify, `secrecy`-wrapped keys, local `config.toml`.
3. Embedded SurrealDB schema + typed repository with the uniqueness constraints from DESIGN §15.

# Technical Approach

Mirror ratrod's `protocol`/`base`/`security` split. Frames are an enum serialized with a fixed
bincode config; the `Hello` handshake negotiates a protocol-version integer (server rejects
incompatible peers). The data frame carries `{ payload | ciphertext-envelope, key_id }` so E2E
slots in later. Identity lives in `identity`, storing an Ed25519 key (private key `SecretBox`)
and a TOML config. The store is embedded SurrealDB accessed through a hand-written repository of
serde structs — the SDK is the typed layer, no ORM.

# Assumptions

- The M0 scaffold (lints, cargo-make, test harness) is green.
- A single embedded SurrealDB instance suffices (no HA in v1).

# Constraints

- The wire format must be forward-compatible from this PRD onward; later PRDs may add frame
  variants but must not renumber/repurpose existing ones without a version bump.
- Private key material must never be logged or written unencrypted to logs (may be plaintext at
  rest per DESIGN §12).

# References to Code

- `docs/DESIGN.md` §5 (identity), §12 (threat model), §13 (protocol/components), §15 (schema).
- ratrod `protocol.rs`, `base.rs`, `security.rs`, `buffed_stream.rs`.

# Non-Goals (MVP)

- E2E encryption itself (only the envelope is reserved) — DESIGN §19.
- Any networking/serving (M2) or MCP/bridge (M3) behavior.

# History
(Entries appended during implementation go below this line.)

## 2026-07-01 — M1 executed (T-001…T-005)

Wire protocol, identity/keystore, and the embedded store landed with TDD; `cargo make ci` is green
(46 tests). All three UATs pass: `test(/protocol/)` (10), `test(/identity/)` (13), `test(/store/)` (8).

Locked-decision notes and deviations (none change the design):

- **Ed25519 → `ed25519-dalek`** (not `ring`): clean raw 32-byte-seed round-trip for the on-disk
  keystore and easy `secrecy::SecretBox` wrapping. Keys are generated from `getrandom` and the
  seed reconstructs a `SigningKey` only transiently per signature — `rand` is avoided entirely
  because `rand 0.10` is incompatible with dalek's `rand_core 0.6`.
- **SurrealDB resolved to v3.1.5** — newer than the PRD's era. 3.x replaced `serde` with its own
  `SurrealValue` typed layer (`content<D: SurrealValue>`, results `R: SurrealValue`, `bind` takes a
  `SurrealValue` object). This is exactly the design's "the SDK is the idiomatic typed layer, no
  ORM": storage records derive `SurrealValue`, domain types stay `serde` + decoupled, and the thin
  repository maps between them. Backend: pure-Rust **SurrealKV** for persistence (clean
  cross-compile) + **kv-mem** for hermetic tests. `$token` is a protected SurrealQL variable, so
  invite lookups bind it as `$tok`.
- **Frames** are `serde`-derived and encoded with `bincode 2` (fixed `standard()` config) behind a
  length-delimited codec; `send`/`recv` are the `ProtocolWrite` / `ProtocolRead` extension traits
  (RPIT, not `async fn` in trait, to stay `-D warnings`-clean). Added a 16 MiB `MAX_FRAME_SIZE`
  decoder cap as basic hardening. The E2E envelope (`Payload::Encrypted { ciphertext, key_id }`) is
  reserved and round-trip-tested now, per the forward-compat requirement.
- Boundary errors are `thiserror` enums in their modules — `protocol::ProtocolError` (also
  `serde`, the wire error frame), `identity::AuthError`, `server::AclError` — each with a `From`
  conversion onto the wire `ProtocolError`. `ProtocolError` carries a narrow, justified
  `#[allow(clippy::module_name_repetitions)]` (the public name is design-mandated).
- New deps: `serde`, `bincode`, `ed25519-dalek`, `getrandom`, `secrecy`, `base64`, `surrealdb`,
  `toml`, `dirs`, `chrono`. The `bridge` module (M3) remains a `//!` stub.

## 2026-07-01 — identity crypto switched to `ring` (supersedes the ed25519-dalek note above)

Consolidated on `ring` for Ed25519 now rather than at the E2E milestone: v2 E2E (§19) also needs
X25519 ECDH + ChaCha20-Poly1305 AEAD + HKDF, and `ring` provides all four under one audited
dependency (matching ratrod). `ring::signature::Ed25519KeyPair::from_seed_unchecked` preserves the
clean raw-32-byte-seed keystore, and `ring`'s `SystemRandom` replaces the `getrandom` dependency
(so `ed25519-dalek` and `getrandom` are both dropped). `Identity::sign` / `from_seed` are now
fallible (ring's Result-based API); the wire and DB formats are unchanged (raw 32-byte keys, 64-byte
signatures), so there is no migration. `cargo make ci` still green (46 tests).
