mcpmesh_local_api/protocol.rs
1//! mcpmesh-local/1 protocol types. Shared vocabulary between the daemon
2//! and its clients (porcelain, connect proxy, later the host shell). Wire framing
3//! is the family NDJSON codec — carried by the caller, not defined here.
4//!
5//! Request/response asymmetry: requests are one typed, closed enum (`Request`);
6//! responses are per-method typed structs deserialized from the JSON-RPC `result`
7//! Value — `Status` → [`StatusResult`], `RegisterService` → an ack, `OpenSession` →
8//! no JSON-RPC result at all: the socket STOPS being JSON-RPC and becomes a raw
9//! byte pipe.
10//!
11//! Additive-only: new fields (capabilities on `Hello`, groups/user_id on
12//! `PeerInfo`, device on `OpenSession`) MUST land as
13//! `#[serde(default, skip_serializing_if = ...)]` so older payloads still deserialize.
14use serde::{Deserialize, Serialize};
15
16/// The first exchange on any `*-local/N` socket (the family's hello convention).
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Hello {
19 pub api: String, // "mcpmesh-local/1"
20 pub api_version: String, // semver of the API major.minor
21 pub stack_version: String,
22}
23
24/// The kind of backend answering a service — the two valid values, enforced at the
25/// type level and kept in lockstep with `BackendSpec`'s variants. Status reports the
26/// kind only, never the command/path (no transport vocabulary).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum BackendKind {
30 Run,
31 Socket,
32}
33
34/// A registered service as reported by `status` (no transport vocabulary).
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct ServiceInfo {
37 pub name: String,
38 pub allow: Vec<String>, // petnames/groups (flat namespace)
39 pub backend: BackendKind, // "run" | "socket" (kind only, never the command/path)
40}
41
42/// A known peer as reported by `status` (petname only — never the EndpointId).
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct PeerInfo {
45 pub name: String,
46 pub services: Vec<String>,
47 /// The peer's PROVEN self-sovereign `user_id` (`b64u:<user_pk>`) if it presented a verified
48 /// device->user binding at pairing (roster peers carry it too), else `None` (petname-only). This
49 /// is a surface-clean identity (an opaque user id, NOT an EndpointId). Additive:
50 /// `#[serde(default, skip_serializing_if = "Option::is_none")]` so older payloads round-trip.
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub user_id: Option<String>,
53}
54
55/// Advisory reachability of a paired peer (pairing-mode liveness). Surface-clean:
56/// a petname + a bool + latency/age NUMBERS — never an endpoint-id, key, or transport path.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct PeerReachability {
59 pub name: String, // the peer's petname
60 pub reachable: bool, // result of the last probe (false if never probed)
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub rtt_ms: Option<u64>, // last measured round-trip, if reachable
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub age_secs: Option<u64>, // None = never probed (consumer shows "checking…")
65}
66
67/// Roster-mode status. Surface-clean roster VOCABULARY only: org_id, serial, a plain
68/// state word, and the pinned org-root FINGERPRINT in short words — never raw keys/EndpointIds/serials-
69/// as-transport-vocab. Absent in a pure-pairing daemon.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct RosterStatus {
72 pub org_id: String,
73 pub serial: u64,
74 pub state: String, // "pending" | "approved" | "degraded" | "stopped"
75 pub org_root_fingerprint: String, // short-word form
76}
77
78/// One reachable roster peer device as reported by `status` (the advisory presence read).
79/// ADVISORY — this is a display convenience, never an authorization surface. Surface-clean:
80/// FLAT vocabulary ONLY — a `user_id`, a human `device_label`, its `role` word, and an `online`
81/// boolean. It carries NO EndpointId / pubkey / hash / ALPN or any transport vocabulary.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct PresencePeer {
84 pub user_id: String,
85 pub device_label: String,
86 pub role: String, // "primary" | "mirror" (roster vocabulary)
87 /// Whether the device has a live presence heartbeat (advisory — absence never blocks a dial).
88 pub online: bool,
89}
90
91/// One recently completed INVITER-side pairing, surfaced by `status` so the inviter's human can
92/// read the short authentication code (SAS) and compare it with the redeemer's out-of-band —
93/// the pairing ceremony is "both humans compare the code": the redeemer sees it in its
94/// [`PairResult`]; this is the inviter's porcelain surface for the same words. DISPLAY-ONLY
95/// ceremony state: held in-memory by the daemon (a small ring), lost on restart, NEVER an
96/// authorization input or trust data. Surface-clean: a petname + the SAS wordlist words +
97/// an epoch — never an EndpointId.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct RecentPairing {
100 /// The peer's petname as stored by the inviter (its local name for the redeemer).
101 pub peer_petname: String,
102 /// The display-only SAS words (e.g. `"tango-fig-cabbage"`) — the same code the redeemer's
103 /// `PairResult.sas_code` carried. Never checked programmatically.
104 pub sas_code: String,
105 /// When the pairing completed (epoch seconds) — the porcelain renders a friendly age.
106 pub paired_at_epoch: u64,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct StatusResult {
111 pub stack_version: String,
112 pub services: Vec<ServiceInfo>,
113 pub peers: Vec<PeerInfo>,
114 /// Roster-mode status, absent in a pure-pairing daemon. Additive:
115 /// `#[serde(default, skip_serializing_if = ...)]` so a daemon/client without it round-trips.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub roster: Option<RosterStatus>,
118 /// The reachable roster peer devices (the advisory presence read), each with an `online`
119 /// flag. Empty in a pure-pairing daemon / when no roster is installed. Additive:
120 /// `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so an older payload round-trips.
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub presence: Vec<PresencePeer>,
123 /// THIS daemon's own self-sovereign `user_id` (`b64u:<user_pk>`), if it has a user key (auto-
124 /// minted at boot; shared by pairing AND roster mode). Lets the operator see + share their stable
125 /// identity that multiple devices resolve to. `None` only when no user key exists. Additive:
126 /// `#[serde(default, skip_serializing_if = "Option::is_none")]` so an older payload round-trips.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub self_user_id: Option<String>,
129 /// Recent INVITER-side pairing completions, newest first (display-only pairing-ceremony aids —
130 /// see [`RecentPairing`]; in-memory on the daemon, cleared by a restart). Empty on a daemon
131 /// that has accepted no pairing since it started. Additive:
132 /// `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so an older payload round-trips.
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub recent_pairings: Vec<RecentPairing>,
135 /// Advisory reachability of paired peers, from the on-demand probe cache. Empty until the
136 /// first probe completes. Additive: default + skip-if-empty.
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub reachability: Vec<PeerReachability>,
139}
140
141/// Params of [`Request::RegisterService`]: the `[services.*]` entry to write/update.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct RegisterServiceParams {
144 pub name: String,
145 pub backend: BackendSpec,
146 pub allow: Vec<String>,
147}
148
149/// Params of [`Request::Invite`]: the services the minted invite grants. `services` is
150/// defaultable — an absent list grants nothing (the documented server-side tolerance).
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct InviteParams {
153 #[serde(default)]
154 pub services: Vec<String>,
155}
156
157/// Params of [`Request::Pair`]: the copyable `mcpmesh-invite:` line. Defaultable — an
158/// absent field reads as an empty line, which simply fails to decode (a clean pair error).
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct PairParams {
161 #[serde(default)]
162 pub invite_line: String,
163}
164
165/// Params of [`Request::PeerRemove`]: the petname to unpair.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct PeerRemoveParams {
168 pub petname: String,
169}
170
171/// Params of [`Request::PeerRename`]: the contact to rename — every device sharing `user_id`
172/// when given, else the single provisional `petname` entry — and the new nickname `to`.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct PeerRenameParams {
175 #[serde(default)]
176 pub user_id: Option<String>,
177 #[serde(default)]
178 pub petname: Option<String>,
179 pub to: String,
180}
181
182/// Params of [`Request::PeerAdd`] (reserved/internal — see the variant): a raw `endpoint_id`
183/// (iroh base32) plus the petname and service allow list to install it under.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct PeerAddParams {
186 pub petname: String,
187 pub endpoint_id: String,
188 #[serde(default)]
189 pub allow: Vec<String>,
190}
191
192/// Params of [`Request::OpenSession`]: the `peer/service` target to dial. Both fields are
193/// defaultable — an empty target simply fails the dial (a clean `-32055` error).
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct OpenSessionParams {
196 #[serde(default)]
197 pub peer: String,
198 #[serde(default)]
199 pub service: String,
200}
201
202/// Params of [`Request::RosterInstall`]: the LOCAL roster file `path`, plus the org-root pin
203/// on FIRST install (`b64u:`; omit once pinned — config carries it).
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct RosterInstallParams {
206 pub path: String,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub org_root_pk: Option<String>,
209}
210
211/// Params of [`Request::OrgJoin`]: the `[identity]` pin. `user_key` is a LOCAL path — the key
212/// never crosses the API.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct OrgJoinParams {
215 pub org_id: String,
216 pub org_root_pk: String,
217 pub user_id: String,
218 pub user_key: String,
219}
220
221/// Params of [`Request::SetRosterUrl`]: the HTTPS roster URL to pin.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct SetRosterUrlParams {
224 pub url: String,
225}
226
227/// Params of [`Request::BlobPublish`]: the scope to publish into and the LOCAL file to add.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct BlobPublishParams {
230 pub scope: String,
231 pub path: String,
232}
233
234/// Params of [`Request::BlobGrant`]: the scope and the flat-namespace principal to grant it to.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236pub struct BlobGrantParams {
237 pub scope: String,
238 pub principal: String,
239}
240
241/// Params of [`Request::BlobFetch`]: the `mcpmesh/blob/1` ticket and the LOCAL export path.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct BlobFetchParams {
244 pub ticket: String,
245 pub dest_path: String,
246}
247
248/// Control-API requests. Serialized as `{ "method": "...", "params": {...} }`
249/// (JSON-RPC-shaped; the id/jsonrpc envelope is added by the transport layer).
250///
251/// Each param-carrying variant wraps its named `*Params` struct — the ONE wire truth for that
252/// method's params, shared by clients (which serialize whole `Request`s) and the daemon (which
253/// deserializes `params` into the same struct after its method-string dispatch). Adjacent
254/// tagging serializes a newtype variant's content as the struct's fields, so the wire shape is
255/// identical to inline variant bodies.
256///
257/// **Servers dispatch on the `method` string and deserialize `params` per-method** — tolerating
258/// omitted / null / empty-object params for parameterless methods — rather than deserializing a
259/// whole message into `Request` (adjacent tagging rejects `params:{}` for unit variants).
260/// This keeps the wire tolerant for third-party clients (the versioned, additive-only surface).
261/// Use [`method_of`] to extract the tag, then match + deserialize `params` per-method.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(tag = "method", content = "params", rename_all = "snake_case")]
264pub enum Request {
265 /// Register/update a `[services.*]` entry idempotently.
266 RegisterService(RegisterServiceParams),
267 Status,
268 /// Mint a one-time pairing invite granting `services`. The daemon
269 /// answers an [`InviteResult`] carrying the copyable `mcpmesh-invite:` line. Tag
270 /// `"invite"` (snake_case). `method_of` needs no per-variant arm — it reads the
271 /// `method` string generically; the tag comes from `rename_all`.
272 Invite(InviteParams),
273 /// Redeem a pairing invite. The daemon dials the inviter named by
274 /// `invite_line` on `mcpmesh/pair/1`, proves the secret, writes the mutual
275 /// (dial-back) `PeerEntry`, and answers a [`PairResult`]. Tag `"pair"`
276 /// (snake_case); `method_of` reads the `method` string generically.
277 ///
278 /// `PeerEntry` — the durable allowlist row — lives in the daemon crate.
279 Pair(PairParams),
280 /// Remove a paired peer by petname (`mcpmesh pair --remove`). The daemon drops the
281 /// peer's `PeerEntry` (identity) AND revokes its access by removing the petname from every
282 /// `[services.*].allow` (authorization) — the inverse of the pairing grant. Idempotent: a
283 /// petname with no entry / no allow membership is a clean no-op. Live in-flight sessions are
284 /// NOT severed here: existing sessions run to completion; the peer only loses the
285 /// ability to establish NEW authorized sessions. Tag `"peer_remove"` (snake_case);
286 /// `method_of` reads the `method` string generically (no per-variant arm).
287 ///
288 /// `PeerEntry` — the durable allowlist row — lives in the daemon crate.
289 PeerRemove(PeerRemoveParams),
290 /// Rename a contact's nickname (petname) authoritatively. Renames the
291 /// PERSON — every `PeerEntry` sharing `user_id` when given (one op for all their devices), else the
292 /// single `petname` entry (a provisional, no-`user_id` contact) — to `to`, AND rewrites the old
293 /// petname → `to` in every `[services.*].allow` so grants follow the rename. Refuses (error frame)
294 /// when `to` is empty or already names/grants a DIFFERENT identity — the same collision guard the
295 /// pairing rendezvous uses, so a rename can't inherit another peer's access. Tag `"peer_rename"`;
296 /// host-privileged like the other pair ops.
297 PeerRename(PeerRenameParams),
298 /// RESERVED / INTERNAL (`docs/local-protocol.md` "Reserved / internal methods"): install a
299 /// peer directly from a raw `endpoint_id` — the trust-population stand-in for pairing behind
300 /// `mcpmesh internal peer add`. A deliberate, documented exception to the surface discipline
301 /// (raw endpoint identifiers otherwise never cross this socket); NOT part of the stable
302 /// vocabulary — do not build on it. Tag `"peer_add"`.
303 PeerAdd(PeerAddParams),
304 /// Open a mesh session to `peer/service`; the daemon dials and pipes.
305 /// Distinct from the proxy's job: this returns a session the client streams.
306 /// Named `open_session` rather than `connect` to avoid colliding
307 /// with the `connect` porcelain.
308 OpenSession(OpenSessionParams),
309 /// Install a signed roster from a local file (the manual `internal roster install` path).
310 /// `path` is a LOCAL file the same-uid daemon reads (the daemon runs as the caller's own
311 /// uid, so passing a path rather than the bytes crosses no trust boundary). `org_root_pk`
312 /// pins the org root on FIRST install (`b64u:`); omit it
313 /// once pinned (config carries it). Tag `"roster_install"`.
314 RosterInstall(RosterInstallParams),
315 /// Pin the org root on a JOINER — WITHOUT a roster (the joiner has none yet; its poll loop
316 /// fetches the first one). Records `[identity]` org_id / org_root_pk / user_id / user_key.
317 /// `user_key` is a LOCAL path
318 /// (the key never crosses the API). Tag `"org_join"`.
319 OrgJoin(OrgJoinParams),
320 /// Pin the HTTPS roster URL (`[roster].url`) in config. Written by `org create
321 /// --roster-url` (the operator keeps it current) AND by `join` when the org invite carries one —
322 /// so the joiner's poll loop bootstraps its FIRST roster. The daemon writes it under
323 /// `reload_lock` (single-writer), then the poll loop picks it up on the next daemon start. Tag
324 /// `"set_roster_url"`.
325 SetRosterUrl(SetRosterUrlParams),
326 /// Publish a LOCAL file INTO a scope: the daemon adds the bytes to its gated
327 /// app-blob store and records the hash in `scope`. `path` is a local file the same-uid daemon
328 /// reads. Answers a [`BlobPublishResult`] carrying the `mcpmesh/blob/1` ticket + hash.
329 /// Tag `"blob_publish"`.
330 BlobPublish(BlobPublishParams),
331 /// Grant a scope to a principal — any flat-namespace entry: a group name, a user_id, or a
332 /// petname (the shared `principal_set` expansion). Tag
333 /// `"blob_grant"`.
334 BlobGrant(BlobGrantParams),
335 /// List the daemon's blob scopes (name → hashes + grants). Tag `"blob_list"`.
336 BlobList,
337 /// Fetch a `mcpmesh/blob/1` ticket THROUGH the daemon (BLAKE3-verified streaming) and export the
338 /// verified blob to `dest_path` (a local file the same-uid daemon writes). Answers a
339 /// [`BlobFetchResult`] with the verified hash + byte length. Tag `"blob_fetch"`.
340 BlobFetch(BlobFetchParams),
341 /// Summarize this node's LOCAL audit log into per-peer / per-service SESSION counts
342 /// (local-only — the daemon reads its OWN audit dir, nothing is transmitted). The host Mesh surface
343 /// renders these as "who serves me / whom I serve / session counts". Parameterless (like `Status`);
344 /// the server dispatches on the `method` string. Tag `"audit_summary"` (snake_case);
345 /// `method_of` reads the `method` string generically (no per-variant arm).
346 AuditSummary,
347 /// Open a live event stream (pairing liveness & health telemetry). Like `open_session`, the
348 /// connection STOPS being request/response after this call and becomes a one-way push stream
349 /// of `StreamFrame`s. Parameterless. Tag `"subscribe"`.
350 Subscribe,
351}
352
353/// Result of [`Request::OrgJoin`] — the pinned org id echoed back (surface-clean; the fingerprint is
354/// computed porcelain-side from the invite's org_root_pk). Additive-only.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct OrgJoinResult {
357 pub org_id: String,
358}
359
360/// Result of a [`Request::RosterInstall`] request (the manual install path): the installed roster's
361/// org id + serial (roster-status vocabulary the confirmation line is permitted to render) plus how
362/// many live sessions the install severed. Surface-clean: NO keys / EndpointIds / paths.
363///
364/// Additive-only: any future field MUST land as
365/// `#[serde(default, skip_serializing_if = ...)]` so older payloads still deserialize.
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367pub struct RosterInstallResult {
368 pub org_id: String,
369 pub serial: u64,
370 /// How many live sessions were severed, for the porcelain's confirmation line.
371 #[serde(default)]
372 pub severed: u32,
373}
374
375/// Result of [`Request::BlobPublish`]: the copyable `mcpmesh/blob/1` ticket + the blob's blake3 hash.
376/// A ticket/hash here is blob-reference vocabulary (NOT a transport-vocab leak — the same
377/// carve-out as the pairing invite line). Additive-only.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379pub struct BlobPublishResult {
380 pub ticket: String,
381 pub hash: String, // bare blake3 hex
382}
383
384/// One scope in a [`BlobScopeList`]: its name + the hashes it contains + the principals it
385/// grants. Flat vocabulary ONLY — no EndpointId/pubkey/ALPN. Additive-only.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387pub struct ScopeInfo {
388 pub name: String,
389 pub hashes: Vec<String>,
390 pub grants: Vec<String>,
391}
392
393/// Result of [`Request::BlobList`]: the daemon's scopes. Additive-only.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct BlobScopeList {
396 pub scopes: Vec<ScopeInfo>,
397}
398
399/// Result of [`Request::BlobFetch`]: the verified hash + byte length written to `dest_path`.
400/// Additive-only.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct BlobFetchResult {
403 pub hash: String,
404 pub bytes_len: u64,
405}
406
407/// Result of [`Request::AuditSummary`]: LOCAL per-peer / per-service session counts
408/// aggregated from this node's OWN audit log — NEVER transmitted (local-only). Surface-clean:
409/// peer names are petnames / user_ids (NEVER EndpointIds), service names are the registered
410/// service names (NEVER transport vocabulary). A "session" is one `SessionOpen` record. `per_peer` /
411/// `per_service` are sorted ascending by name (deterministic). Tuples mirror kb's
412/// `InsightResponse::per_peer_contribution` — `["bob", 2]` on the wire.
413///
414/// Additive-only: any future field MUST land as
415/// `#[serde(default, skip_serializing_if = ...)]` so older payloads still deserialize.
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct AuditSummaryResult {
418 /// Sessions opened per peer (petname). A session with no attributed peer is NOT counted here (no
419 /// peer to attribute) but IS in `total_sessions`.
420 pub per_peer: Vec<(String, u64)>,
421 /// Sessions opened per registered service name.
422 pub per_service: Vec<(String, u64)>,
423 /// Total sessions opened (every `SessionOpen` record, including peer-less ones).
424 #[serde(default)]
425 pub total_sessions: u64,
426}
427
428/// Result of an [`Request::Invite`] request: the copyable `mcpmesh-invite:` artifact
429/// (the ONE pairing artifact deliberately carved out of the
430/// transport-vocabulary blocklist, so this is NOT a transport-vocab leak) plus its
431/// absolute expiry in epoch seconds (≤ now + 24h).
432///
433/// Additive-only: any future field (e.g. the computed SAS, once the inviter side
434/// surfaces it) MUST land as `#[serde(default, skip_serializing_if = ...)]` so older
435/// payloads still deserialize.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437pub struct InviteResult {
438 /// The `mcpmesh-invite:<base32>` line, copied out-of-band to the redeemer.
439 pub invite_line: String,
440 /// When the invite expires (epoch seconds); the daemon burns it at redemption or expiry.
441 pub expires_at_epoch: u64,
442}
443
444/// Result of a [`Request::Pair`] request: the inviter's suggested petname (the
445/// redeemer's local name for the new peer) plus the display-only short authentication
446/// code (SAS) — a few words the human reads aloud to a second channel to
447/// catch a whole-invite forgery / address-swap MITM. The SAS is a pairing-ceremony
448/// artifact (like the invite line), NOT a transport-vocabulary leak.
449///
450/// Additive-only: any future field MUST land as
451/// `#[serde(default, skip_serializing_if = ...)]` so older payloads still deserialize.
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453pub struct PairResult {
454 /// The inviter's suggested petname (from the invite) — the redeemer's local name for it.
455 pub peer_petname: String,
456 /// The display-only short authentication code (e.g. `"tango-fig-42"`), shown on both
457 /// sides for the out-of-band human check. Never sent on the wire, never checked
458 /// programmatically.
459 pub sas_code: String,
460 /// The services this pairing granted the redeemer — each mountable as `<peer>/<service>`.
461 /// Populated from the invite (`invite.services`) by the redeemer-side `redeem_invite`, so
462 /// the porcelain can print the "You can mount: alice/notes" line without re-decoding the
463 /// invite. Additive: `#[serde(default, skip_serializing_if = ...)]` so a `PairResult`
464 /// minted by an older daemon (which omits `services`) still deserializes — to an empty list.
465 #[serde(default, skip_serializing_if = "Vec::is_empty")]
466 pub services: Vec<String>,
467}
468
469/// The event class of an [`AuditRecord`] (the four audit event classes). An additive discriminant on
470/// top of the base record schema: it removes no field and makes the JSONL self-describing so
471/// a consumer can filter by class without guessing from which optional fields are present.
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(rename_all = "snake_case")]
474pub enum AuditKind {
475 /// A mesh session opened (a backend was selected for an authenticated peer).
476 /// (A `session_open` with `status:"error"` is a synthesized FAILED-dial marker — no backend
477 /// was reached; it records an attempted-and-failed reach for the telemetry stream.)
478 SessionOpen,
479 /// A mesh session closed (the backend returned / the session tore down).
480 SessionClose,
481 /// One proxied MCP request line (method + tool NAME + args_hash). NEVER carries raw arguments.
482 Request,
483 /// A peer fetched a blob from this node's gated provider (peer + hash + allow/deny).
484 BlobFetch,
485 /// A trust mutation (pair, unpair, roster install/swap, revoke).
486 Trust,
487}
488
489/// One audit record — the union of the event classes, and the `record` payload of a
490/// [`StreamFrame::Event`]. ONE schema for the on-disk JSONL log and the live stream. Every field
491/// beyond `ts`/`kind` is optional and elided when absent (`skip_serializing_if`), so each class
492/// serializes to just its relevant keys (a session record has no `method`; a trust record has no
493/// `bytes_out`).
494///
495/// PRIVACY: the proxied-request record carries `method` + `tool` (NAME only) +
496/// `args_hash` (`"blake3:<hex>"`), and NEVER the raw arguments, the request/response content, or
497/// any tool-output bytes — only a `bytes_out` COUNT and a `status`.
498#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
499pub struct AuditRecord {
500 /// RFC3339 UTC with millisecond precision, e.g. `"2026-07-03T14:02:11.480Z"`. The `YYYY-MM`
501 /// prefix also selects the monthly file (the rotation boundary), so it is always present.
502 pub ts: String,
503 pub kind: AuditKind,
504 /// The gate-resolved authenticated peer (attributed by the endpoint_id-keyed trust gate). Absent on
505 /// local-only events with no remote peer (a manual roster install).
506 #[serde(skip_serializing_if = "Option::is_none")]
507 pub peer: Option<String>,
508 #[serde(skip_serializing_if = "Option::is_none")]
509 pub service: Option<String>,
510 #[serde(skip_serializing_if = "Option::is_none")]
511 pub method: Option<String>,
512 /// The tool NAME only (never its arguments or output) — e.g. `"read_file"` for a `tools/call`.
513 #[serde(skip_serializing_if = "Option::is_none")]
514 pub tool: Option<String>,
515 /// `"blake3:<hex>"` of the request arguments. The raw arguments are NEVER stored.
516 #[serde(skip_serializing_if = "Option::is_none")]
517 pub args_hash: Option<String>,
518 /// Byte COUNT of the response sent back to the peer — a count, never the content.
519 #[serde(skip_serializing_if = "Option::is_none")]
520 pub bytes_out: Option<u64>,
521 /// `"ok"` / `"error"` (proxied request) or `"ok"` / `"denied"` (blob fetch).
522 #[serde(skip_serializing_if = "Option::is_none")]
523 pub status: Option<String>,
524 #[serde(skip_serializing_if = "Option::is_none")]
525 pub latency_ms: Option<u64>,
526 /// Trust-event verb: `"pair"` / `"unpair"` / `"roster_install"` / `"revoke"` (kind == Trust).
527 #[serde(skip_serializing_if = "Option::is_none")]
528 pub event: Option<String>,
529 /// A reference, NEVER content: a blob hash (`BlobFetch`) or a trust-event target such as a
530 /// petname or `org/serial` (`Trust`).
531 #[serde(skip_serializing_if = "Option::is_none")]
532 pub target: Option<String>,
533}
534
535impl AuditRecord {
536 fn base(ts: String, kind: AuditKind) -> Self {
537 Self {
538 ts,
539 kind,
540 peer: None,
541 service: None,
542 method: None,
543 tool: None,
544 args_hash: None,
545 bytes_out: None,
546 status: None,
547 latency_ms: None,
548 event: None,
549 target: None,
550 }
551 }
552
553 pub fn session_open(ts: String, peer: Option<String>, service: String) -> Self {
554 let mut r = Self::base(ts, AuditKind::SessionOpen);
555 r.peer = peer;
556 r.service = Some(service);
557 r
558 }
559
560 /// Set the record's `status` (`"ok"`/`"error"`/`"denied"`), returning `self` for chaining.
561 /// Marks a synthesized failure record — e.g. the `session_open` for a FAILED dial, which
562 /// reaches no backend and so is never audited by the far side's session guard — without a
563 /// dedicated constructor. DRY: reuses the existing optional `status` field.
564 pub fn with_status(mut self, status: &str) -> Self {
565 self.status = Some(status.into());
566 self
567 }
568
569 pub fn session_close(ts: String, peer: Option<String>, service: String) -> Self {
570 let mut r = Self::base(ts, AuditKind::SessionClose);
571 r.peer = peer;
572 r.service = Some(service);
573 r
574 }
575
576 /// A completed (request→response correlated) proxied line: method + tool NAME + args_hash, plus
577 /// the response's `bytes_out` COUNT, `status`, and `latency_ms`. PRIVACY: `args_hash` is a digest;
578 /// no raw arguments, request/response content, or tool-output bytes are ever passed in.
579 #[allow(clippy::too_many_arguments)]
580 pub fn proxied_request(
581 ts: String,
582 peer: Option<String>,
583 service: String,
584 method: String,
585 tool: Option<String>,
586 args_hash: String,
587 bytes_out: u64,
588 status: String,
589 latency_ms: u64,
590 ) -> Self {
591 let mut r = Self::base(ts, AuditKind::Request);
592 r.peer = peer;
593 r.service = Some(service);
594 r.method = Some(method);
595 r.tool = tool;
596 r.args_hash = Some(args_hash);
597 r.bytes_out = Some(bytes_out);
598 r.status = Some(status);
599 r.latency_ms = Some(latency_ms);
600 r
601 }
602
603 /// A proxied NOTIFICATION line (no `id`, so no response correlates): method + tool + args_hash,
604 /// no `bytes_out`/`status`/`latency_ms`. The line is still recorded — every proxied request is audited.
605 pub fn proxied_notification(
606 ts: String,
607 peer: Option<String>,
608 service: String,
609 method: String,
610 tool: Option<String>,
611 args_hash: String,
612 ) -> Self {
613 let mut r = Self::base(ts, AuditKind::Request);
614 r.peer = peer;
615 r.service = Some(service);
616 r.method = Some(method);
617 r.tool = tool;
618 r.args_hash = Some(args_hash);
619 r
620 }
621
622 pub fn blob_fetch(ts: String, peer: Option<String>, hash: String, status: String) -> Self {
623 let mut r = Self::base(ts, AuditKind::BlobFetch);
624 r.peer = peer;
625 r.target = Some(hash);
626 r.status = Some(status);
627 r
628 }
629
630 pub fn trust(ts: String, event: String, target: Option<String>) -> Self {
631 let mut r = Self::base(ts, AuditKind::Trust);
632 r.event = Some(event);
633 r.target = target;
634 r
635 }
636}
637
638/// One live mesh session, in a [`StreamFrame::Snapshot`]. Surface-clean: `peer` is the
639/// user_id-or-petname the audit records carry, never an endpoint-id. `opened_at` is epoch seconds.
640#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
641pub struct ActiveSession {
642 pub peer: String,
643 pub service: String,
644 pub opened_at: i64,
645}
646
647/// One frame of the [`Request::Subscribe`] stream (pairing liveness & health telemetry). Tagged on
648/// `type` (snake_case), so a frame is `{"type":"snapshot",...}` / `{"type":"event",...}` /
649/// `{"type":"lagged",...}`. `Event.record` is the [`AuditRecord`] verbatim, so the stream and the
650/// on-disk log carry ONE schema. The daemon serializes these; an embedding consumer deserializes
651/// them (see `docs/local-protocol.md` "Live event stream").
652#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
653#[serde(tag = "type", rename_all = "snake_case")]
654pub enum StreamFrame {
655 /// The FIRST frame: a point-in-time picture of the mesh (open sessions + paired-peer
656 /// reachability) so a fresh subscriber renders immediately without replaying history.
657 Snapshot {
658 active_sessions: Vec<ActiveSession>,
659 reachability: Vec<PeerReachability>,
660 },
661 /// A live audit event (session open/close, request, blob fetch, trust) — the tap on the hub.
662 /// Boxed so this (much larger) variant does not bloat every frame; serde delegates through the
663 /// `Box`, so the wire shape is the record's fields verbatim.
664 Event { record: Box<AuditRecord> },
665 /// The subscriber fell `dropped` records behind the broadcast ring; the stream continues (a
666 /// fresh reconnect would re-`Snapshot`). Never drops the subscriber — lag is reported, never fatal.
667 Lagged { dropped: u64 },
668}
669
670/// Extract the `method` tag from a raw request value without deserializing the whole
671/// message. The daemon's dispatcher uses this: match on the method string, then deserialize
672/// `params` per-method — which tolerates omitted / null / `{}` params for parameterless
673/// methods (adjacent tagging rejects `params:{}` on unit variants).
674pub fn method_of(v: &serde_json::Value) -> Option<&str> {
675 v.get("method").and_then(serde_json::Value::as_str)
676}
677
678/// How a service is answered. Mirrors the config `[services.*]` *kinds*;
679/// Config→BackendSpec is a hand-written match, not a serde passthrough.
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
681#[serde(rename_all = "snake_case")]
682pub enum BackendSpec {
683 Run { cmd: Vec<String> },
684 Socket { path: String },
685}
686
687pub const API_NAME: &str = "mcpmesh-local/1";
688pub const API_VERSION: &str = "1.0";
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
695 fn peer_reachability_serde_is_additive() {
696 let r = PeerReachability {
697 name: "bob".into(),
698 reachable: true,
699 rtt_ms: Some(42),
700 age_secs: Some(3),
701 };
702 let v = serde_json::to_value(&r).unwrap();
703 assert_eq!(v["name"], "bob");
704 assert_eq!(v["reachable"], true);
705 assert_eq!(v["rtt_ms"], 42);
706 assert_eq!(v["age_secs"], 3);
707 // Never-probed peer: optionals elided, not null.
708 let unknown = PeerReachability {
709 name: "carol".into(),
710 reachable: false,
711 rtt_ms: None,
712 age_secs: None,
713 };
714 let uv = serde_json::to_value(&unknown).unwrap();
715 assert!(uv.get("rtt_ms").is_none() && uv.get("age_secs").is_none());
716 // An older StatusResult (no reachability field) still deserializes.
717 let old = serde_json::json!({"stack_version":"0.1.0","services":[],"peers":[]});
718 let s: StatusResult = serde_json::from_value(old).unwrap();
719 assert!(s.reachability.is_empty());
720 }
721
722 #[test]
723 fn subscribe_method_tag_resolves() {
724 let req = serde_json::to_value(Request::Subscribe).unwrap();
725 assert_eq!(method_of(&req), Some("subscribe"));
726 }
727
728 #[test]
729 fn hello_result_roundtrips() {
730 let h = Hello {
731 api: "mcpmesh-local/1".into(),
732 api_version: "1.0".into(),
733 stack_version: "0.1.0".into(),
734 };
735 let v = serde_json::to_value(&h).unwrap();
736 assert_eq!(v["api"], "mcpmesh-local/1");
737 let back: Hello = serde_json::from_value(v).unwrap();
738 assert_eq!(back, h);
739 }
740
741 #[test]
742 fn request_tagged_by_method() {
743 let r = Request::Status;
744 assert_eq!(serde_json::to_value(&r).unwrap()["method"], "status");
745 let r = Request::OpenSession(OpenSessionParams {
746 peer: "alice".into(),
747 service: "notes".into(),
748 });
749 let v = serde_json::to_value(&r).unwrap();
750 assert_eq!(v["method"], "open_session");
751 assert_eq!(v["params"]["peer"], "alice");
752 }
753
754 #[test]
755 fn parameterless_method_tolerates_params_forms() {
756 // Omitted and null params deserialize straight into the unit variant.
757 let omitted: Request =
758 serde_json::from_value(serde_json::json!({"method": "status"})).unwrap();
759 assert_eq!(omitted, Request::Status);
760 let null: Request =
761 serde_json::from_value(serde_json::json!({"method": "status", "params": null}))
762 .unwrap();
763 assert_eq!(null, Request::Status);
764
765 // Known limitation: adjacent tagging rejects `params:{}` for a unit variant, so
766 // the server MUST dispatch on the method string rather than deserialize the whole
767 // message into `Request`. This is the pattern the daemon's dispatcher uses.
768 let empty = serde_json::json!({"method": "status", "params": {}});
769 assert!(serde_json::from_value::<Request>(empty.clone()).is_err());
770 match method_of(&empty) {
771 Some("status") => {} // dispatcher resolves Status via the method string
772 other => panic!("method_of failed to resolve status: {other:?}"),
773 }
774 }
775
776 #[test]
777 fn backend_spec_roundtrips() {
778 let run = BackendSpec::Run {
779 cmd: vec!["notes-mcp".into(), "--stdio".into()],
780 };
781 let v = serde_json::to_value(&run).unwrap();
782 assert_eq!(v["run"]["cmd"][0], "notes-mcp");
783 assert_eq!(serde_json::from_value::<BackendSpec>(v).unwrap(), run);
784
785 let sock = BackendSpec::Socket {
786 path: "/run/notes.sock".into(),
787 };
788 let v = serde_json::to_value(&sock).unwrap();
789 assert_eq!(v["socket"]["path"], "/run/notes.sock");
790 assert_eq!(serde_json::from_value::<BackendSpec>(v).unwrap(), sock);
791 }
792
793 #[test]
794 fn register_service_wire_shape() {
795 let r = Request::RegisterService(RegisterServiceParams {
796 name: "notes".into(),
797 backend: BackendSpec::Run {
798 cmd: vec!["notes-mcp".into()],
799 },
800 allow: vec!["alice".into()],
801 });
802 let v = serde_json::to_value(&r).unwrap();
803 assert_eq!(
804 v,
805 serde_json::json!({
806 "method": "register_service",
807 "params": {
808 "name": "notes",
809 "backend": {"run": {"cmd": ["notes-mcp"]}},
810 "allow": ["alice"],
811 }
812 })
813 );
814 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
815 }
816
817 #[test]
818 fn invite_request_and_result_roundtrip() {
819 // Request::Invite → `{ "method": "invite", "params": { "services": [...] } }`.
820 let r = Request::Invite(InviteParams {
821 services: vec!["notes".into(), "kb".into()],
822 });
823 let v = serde_json::to_value(&r).unwrap();
824 assert_eq!(v["method"], "invite");
825 assert_eq!(v["params"]["services"][0], "notes");
826 assert_eq!(v["params"]["services"][1], "kb");
827 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
828 // method_of resolves the tag generically (no per-variant arm).
829 assert_eq!(
830 method_of(&serde_json::json!({"method": "invite", "params": {"services": []}})),
831 Some("invite")
832 );
833
834 // InviteResult carries the copyable line + expiry (surface #2 pairing artifact).
835 let res = InviteResult {
836 invite_line: "mcpmesh-invite:ABCDEF".into(),
837 expires_at_epoch: 1_800_000_000,
838 };
839 let v = serde_json::to_value(&res).unwrap();
840 assert_eq!(v["invite_line"], "mcpmesh-invite:ABCDEF");
841 assert_eq!(v["expires_at_epoch"], 1_800_000_000u64);
842 assert_eq!(serde_json::from_value::<InviteResult>(v).unwrap(), res);
843 }
844
845 #[test]
846 fn pair_request_and_result_roundtrip() {
847 // Request::Pair → `{ "method": "pair", "params": { "invite_line": "..." } }`.
848 let r = Request::Pair(PairParams {
849 invite_line: "mcpmesh-invite:ABCDEF".into(),
850 });
851 let v = serde_json::to_value(&r).unwrap();
852 assert_eq!(v["method"], "pair");
853 assert_eq!(v["params"]["invite_line"], "mcpmesh-invite:ABCDEF");
854 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
855 // method_of resolves the tag generically (no per-variant arm).
856 assert_eq!(
857 method_of(&serde_json::json!({"method": "pair", "params": {"invite_line": "x"}})),
858 Some("pair")
859 );
860
861 // PairResult carries the inviter's suggested petname + the display-only SAS words +
862 // the granted services (the porcelain renders each as `<peer>/<service>`).
863 let res = PairResult {
864 peer_petname: "alice".into(),
865 sas_code: "tango-fig-cabbage".into(),
866 services: vec!["notes".into(), "kb".into()],
867 };
868 let v = serde_json::to_value(&res).unwrap();
869 assert_eq!(v["peer_petname"], "alice");
870 assert_eq!(v["sas_code"], "tango-fig-cabbage");
871 assert_eq!(v["services"][0], "notes");
872 assert_eq!(v["services"][1], "kb");
873 assert_eq!(serde_json::from_value::<PairResult>(v).unwrap(), res);
874
875 // Additive-only: a PairResult minted by an older daemon (no `services` key) still
876 // deserializes — the `#[serde(default)]` fills it with an empty list.
877 let old_shape = serde_json::json!({
878 "peer_petname": "alice",
879 "sas_code": "tango-fig-cabbage",
880 });
881 let back: PairResult = serde_json::from_value(old_shape).unwrap();
882 assert_eq!(back.peer_petname, "alice");
883 assert!(back.services.is_empty());
884 }
885
886 #[test]
887 fn roster_install_request_and_result_roundtrip() {
888 // Request::RosterInstall → `{ "method": "roster_install", "params": { "path": ...,
889 // "org_root_pk": ... } }`. The optional pk is present on the first-install shape.
890 let r = Request::RosterInstall(RosterInstallParams {
891 path: "/tmp/roster.json".into(),
892 org_root_pk: Some("b64u:AAAA".into()),
893 });
894 let v = serde_json::to_value(&r).unwrap();
895 assert_eq!(v["method"], "roster_install");
896 assert_eq!(v["params"]["path"], "/tmp/roster.json");
897 assert_eq!(v["params"]["org_root_pk"], "b64u:AAAA");
898 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
899 // method_of resolves the tag generically (no per-variant arm).
900 assert_eq!(
901 method_of(&serde_json::json!({"method": "roster_install", "params": {"path": "/x"}})),
902 Some("roster_install")
903 );
904
905 // When the pk is omitted (a subsequent install using the pinned value), it is
906 // `skip_serializing_if`-dropped from the wire and deserializes back to `None`.
907 let omit = Request::RosterInstall(RosterInstallParams {
908 path: "/tmp/roster.json".into(),
909 org_root_pk: None,
910 });
911 let v = serde_json::to_value(&omit).unwrap();
912 assert!(
913 v["params"].get("org_root_pk").is_none(),
914 "an omitted org_root_pk must not appear on the wire: {v}"
915 );
916 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), omit);
917
918 // RosterInstallResult carries org_id + serial + severed count (roster-status vocabulary).
919 let res = RosterInstallResult {
920 org_id: "acme".into(),
921 serial: 42,
922 severed: 1,
923 };
924 let v = serde_json::to_value(&res).unwrap();
925 assert_eq!(v["org_id"], "acme");
926 assert_eq!(v["serial"], 42u64);
927 assert_eq!(v["severed"], 1u32);
928 assert_eq!(
929 serde_json::from_value::<RosterInstallResult>(v).unwrap(),
930 res
931 );
932
933 // Additive-only: a result minted by an older daemon (no `severed` key) still
934 // deserializes — the `#[serde(default)]` fills it with 0.
935 let old_shape = serde_json::json!({ "org_id": "acme", "serial": 7 });
936 let back: RosterInstallResult = serde_json::from_value(old_shape).unwrap();
937 assert_eq!(back.serial, 7);
938 assert_eq!(back.severed, 0);
939 }
940
941 #[test]
942 fn org_join_request_and_result_roundtrip() {
943 // Request::OrgJoin → `{ "method": "org_join", "params": { org_id, org_root_pk, user_id,
944 // user_key } }`. `user_key` is a LOCAL path string (the key never crosses the API).
945 let r = Request::OrgJoin(OrgJoinParams {
946 org_id: "acme".into(),
947 org_root_pk: "b64u:AAAA".into(),
948 user_id: "alice".into(),
949 user_key: "/home/alice/.config/mcpmesh/user.key".into(),
950 });
951 let v = serde_json::to_value(&r).unwrap();
952 assert_eq!(v["method"], "org_join");
953 assert_eq!(v["params"]["org_id"], "acme");
954 assert_eq!(v["params"]["org_root_pk"], "b64u:AAAA");
955 assert_eq!(v["params"]["user_id"], "alice");
956 assert_eq!(
957 v["params"]["user_key"],
958 "/home/alice/.config/mcpmesh/user.key"
959 );
960 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
961 // method_of resolves the tag generically (no per-variant arm).
962 assert_eq!(
963 method_of(&serde_json::json!({"method": "org_join", "params": {"org_id": "x"}})),
964 Some("org_join")
965 );
966
967 // OrgJoinResult echoes the pinned org id (surface-clean; the fingerprint is porcelain-side).
968 let res = OrgJoinResult {
969 org_id: "acme".into(),
970 };
971 let v = serde_json::to_value(&res).unwrap();
972 assert_eq!(v["org_id"], "acme");
973 assert_eq!(serde_json::from_value::<OrgJoinResult>(v).unwrap(), res);
974 }
975
976 #[test]
977 fn set_roster_url_request_roundtrip() {
978 // Request::SetRosterUrl → `{ "method": "set_roster_url", "params": { "url": "..." } }`.
979 let r = Request::SetRosterUrl(SetRosterUrlParams {
980 url: "https://intranet.acme.com/roster.json".into(),
981 });
982 let v = serde_json::to_value(&r).unwrap();
983 assert_eq!(v["method"], "set_roster_url");
984 assert_eq!(v["params"]["url"], "https://intranet.acme.com/roster.json");
985 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
986 assert_eq!(
987 method_of(&serde_json::json!({"method": "set_roster_url", "params": {"url": "x"}})),
988 Some("set_roster_url")
989 );
990 }
991
992 #[test]
993 fn peer_remove_request_roundtrip() {
994 // Request::PeerRemove → `{ "method": "peer_remove", "params": { "petname": "..." } }`.
995 let r = Request::PeerRemove(PeerRemoveParams {
996 petname: "bob".into(),
997 });
998 let v = serde_json::to_value(&r).unwrap();
999 assert_eq!(v["method"], "peer_remove");
1000 assert_eq!(v["params"]["petname"], "bob");
1001 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
1002 // method_of resolves the tag generically (no per-variant arm).
1003 assert_eq!(
1004 method_of(&serde_json::json!({"method": "peer_remove", "params": {"petname": "bob"}})),
1005 Some("peer_remove")
1006 );
1007 }
1008
1009 /// The reserved/internal `peer_add` rides the SAME typed vocabulary as every other method —
1010 /// `{ "method": "peer_add", "params": { petname, endpoint_id, allow } }` — with `allow`
1011 /// defaulting to empty when absent.
1012 #[test]
1013 fn peer_add_request_roundtrip() {
1014 let r = Request::PeerAdd(PeerAddParams {
1015 petname: "bob".into(),
1016 endpoint_id: "96246d3f".into(),
1017 allow: vec!["notes".into()],
1018 });
1019 let v = serde_json::to_value(&r).unwrap();
1020 assert_eq!(v["method"], "peer_add");
1021 assert_eq!(v["params"]["petname"], "bob");
1022 assert_eq!(v["params"]["endpoint_id"], "96246d3f");
1023 assert_eq!(v["params"]["allow"][0], "notes");
1024 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
1025 // An absent allow list deserializes to empty (the server-side tolerance).
1026 let p: PeerAddParams =
1027 serde_json::from_value(serde_json::json!({"petname": "bob", "endpoint_id": "x"}))
1028 .unwrap();
1029 assert!(p.allow.is_empty());
1030 }
1031
1032 #[test]
1033 fn peer_rename_request_roundtrip() {
1034 // By user_id (renames all of a person's devices in one op).
1035 let r = Request::PeerRename(PeerRenameParams {
1036 user_id: Some("b64u:BOB".into()),
1037 petname: None,
1038 to: "Bobby".into(),
1039 });
1040 let v = serde_json::to_value(&r).unwrap();
1041 assert_eq!(v["method"], "peer_rename");
1042 assert_eq!(v["params"]["user_id"], "b64u:BOB");
1043 assert_eq!(v["params"]["to"], "Bobby");
1044 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
1045 // A provisional contact is renamed by petname; omitted user_id defaults to None.
1046 assert_eq!(
1047 method_of(
1048 &serde_json::json!({"method": "peer_rename", "params": {"petname": "carol", "to": "Carol"}})
1049 ),
1050 Some("peer_rename")
1051 );
1052 }
1053
1054 #[test]
1055 fn status_result_roundtrips() {
1056 // Pure-pairing daemon: `roster` is None — absent from the wire (skip_serializing_if) and an
1057 // older payload with no `roster` key still deserializes to None (serde default).
1058 let s = StatusResult {
1059 stack_version: "0.1.0".into(),
1060 services: vec![ServiceInfo {
1061 name: "notes".into(),
1062 allow: vec!["alice".into()],
1063 backend: BackendKind::Run,
1064 }],
1065 peers: vec![PeerInfo {
1066 name: "alice".into(),
1067 services: vec!["notes".into()],
1068 // A paired peer that proved a self-sovereign user_id at pairing (surface-clean id).
1069 user_id: Some("b64u:alicepk".into()),
1070 }],
1071 roster: None,
1072 presence: vec![],
1073 self_user_id: Some("b64u:selfpk".into()),
1074 recent_pairings: vec![],
1075 reachability: vec![],
1076 };
1077 let v = serde_json::to_value(&s).unwrap();
1078 assert_eq!(v["services"][0]["backend"], "run");
1079 // The additive identity fields ride the wire when present.
1080 assert_eq!(v["peers"][0]["user_id"], "b64u:alicepk");
1081 assert_eq!(v["self_user_id"], "b64u:selfpk");
1082 assert!(
1083 v.get("roster").is_none(),
1084 "an absent roster must not appear on the wire: {v}"
1085 );
1086 assert!(
1087 v.get("presence").is_none(),
1088 "an empty presence must not appear on the wire: {v}"
1089 );
1090 assert!(
1091 v.get("recent_pairings").is_none(),
1092 "an empty recent_pairings must not appear on the wire: {v}"
1093 );
1094 assert_eq!(serde_json::from_value::<StatusResult>(v).unwrap(), s);
1095
1096 // A payload minted by an older daemon (no `roster`/`presence`/identity keys) still
1097 // deserializes — the identity fields default to None / a petname-only peer.
1098 let old_shape = serde_json::json!({
1099 "stack_version": "0.1.0",
1100 "services": [],
1101 "peers": [{ "name": "bob", "services": [] }],
1102 });
1103 let back: StatusResult = serde_json::from_value(old_shape).unwrap();
1104 assert!(back.roster.is_none());
1105 assert!(back.presence.is_empty());
1106 assert!(back.self_user_id.is_none());
1107 assert!(back.peers[0].user_id.is_none());
1108 assert!(back.recent_pairings.is_empty());
1109
1110 // Roster daemon: a Some(RosterStatus) + an advisory presence list round-trip. `presence`
1111 // carries FLAT vocabulary only (user_id/device_label/role/online) — no EndpointId/key.
1112 let s = StatusResult {
1113 stack_version: "0.1.0".into(),
1114 services: vec![],
1115 peers: vec![],
1116 roster: Some(RosterStatus {
1117 org_id: "acme".into(),
1118 serial: 42,
1119 state: "approved".into(),
1120 org_root_fingerprint: "tango-fig-cabbage-anchor".into(),
1121 }),
1122 presence: vec![
1123 PresencePeer {
1124 user_id: "alice".into(),
1125 device_label: "laptop".into(),
1126 role: "primary".into(),
1127 online: true,
1128 },
1129 PresencePeer {
1130 user_id: "alice".into(),
1131 device_label: "desktop".into(),
1132 role: "mirror".into(),
1133 online: false,
1134 },
1135 ],
1136 self_user_id: None,
1137 recent_pairings: vec![],
1138 reachability: vec![],
1139 };
1140 let v = serde_json::to_value(&s).unwrap();
1141 assert_eq!(v["roster"]["org_id"], "acme");
1142 assert_eq!(v["roster"]["serial"], 42u64);
1143 assert_eq!(v["roster"]["state"], "approved");
1144 assert_eq!(
1145 v["roster"]["org_root_fingerprint"],
1146 "tango-fig-cabbage-anchor"
1147 );
1148 assert_eq!(v["presence"][0]["user_id"], "alice");
1149 assert_eq!(v["presence"][0]["device_label"], "laptop");
1150 assert_eq!(v["presence"][0]["role"], "primary");
1151 assert_eq!(v["presence"][0]["online"], true);
1152 assert_eq!(v["presence"][1]["online"], false);
1153 assert_eq!(serde_json::from_value::<StatusResult>(v).unwrap(), s);
1154 }
1155
1156 /// The `recent_pairings` status field is ADDITIVE: a populated list round-trips with
1157 /// the flat `{peer_petname, sas_code, paired_at_epoch}` shape (petname + SAS words + epoch —
1158 /// never an EndpointId), an empty list is dropped from the wire, and a payload minted by an
1159 /// older daemon (no key at all) still deserializes to empty.
1160 #[test]
1161 fn recent_pairings_are_additive_on_status() {
1162 let s = StatusResult {
1163 stack_version: "0.1.0".into(),
1164 services: vec![],
1165 peers: vec![],
1166 roster: None,
1167 presence: vec![],
1168 self_user_id: None,
1169 recent_pairings: vec![RecentPairing {
1170 peer_petname: "bob".into(),
1171 sas_code: "tango-fig-cabbage".into(),
1172 paired_at_epoch: 1_800_000_000,
1173 }],
1174 reachability: vec![],
1175 };
1176 let v = serde_json::to_value(&s).unwrap();
1177 assert_eq!(v["recent_pairings"][0]["peer_petname"], "bob");
1178 assert_eq!(v["recent_pairings"][0]["sas_code"], "tango-fig-cabbage");
1179 assert_eq!(v["recent_pairings"][0]["paired_at_epoch"], 1_800_000_000u64);
1180 assert_eq!(serde_json::from_value::<StatusResult>(v).unwrap(), s);
1181
1182 // A payload minted by an OLDER daemon (no `recent_pairings` key) still deserializes —
1183 // the `#[serde(default)]` fills it with an empty list.
1184 let old_shape = serde_json::json!({
1185 "stack_version": "0.1.0",
1186 "services": [],
1187 "peers": [],
1188 });
1189 let back: StatusResult = serde_json::from_value(old_shape).unwrap();
1190 assert!(back.recent_pairings.is_empty());
1191 }
1192
1193 #[test]
1194 fn blob_requests_and_results_roundtrip() {
1195 // BlobPublish → { method, params: { scope, path } }.
1196 let r = Request::BlobPublish(BlobPublishParams {
1197 scope: "docs".into(),
1198 path: "/tmp/a.bin".into(),
1199 });
1200 let v = serde_json::to_value(&r).unwrap();
1201 assert_eq!(v["method"], "blob_publish");
1202 assert_eq!(v["params"]["scope"], "docs");
1203 assert_eq!(v["params"]["path"], "/tmp/a.bin");
1204 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
1205
1206 // BlobGrant → { method, params: { scope, principal } }.
1207 let r = Request::BlobGrant(BlobGrantParams {
1208 scope: "docs".into(),
1209 principal: "alice".into(),
1210 });
1211 let v = serde_json::to_value(&r).unwrap();
1212 assert_eq!(v["method"], "blob_grant");
1213 assert_eq!(v["params"]["principal"], "alice");
1214 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
1215
1216 // BlobList is parameterless (method_of resolves it).
1217 assert_eq!(
1218 method_of(&serde_json::json!({"method": "blob_list"})),
1219 Some("blob_list")
1220 );
1221
1222 // BlobFetch → { method, params: { ticket, dest_path } }.
1223 let r = Request::BlobFetch(BlobFetchParams {
1224 ticket: "blobAAA".into(),
1225 dest_path: "/tmp/out.bin".into(),
1226 });
1227 let v = serde_json::to_value(&r).unwrap();
1228 assert_eq!(v["method"], "blob_fetch");
1229 assert_eq!(v["params"]["ticket"], "blobAAA");
1230 assert_eq!(v["params"]["dest_path"], "/tmp/out.bin");
1231 assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
1232
1233 // BlobPublishResult carries the ticket + hash (blob-reference vocabulary).
1234 let res = BlobPublishResult {
1235 ticket: "blobAAA".into(),
1236 hash: "ab".repeat(32),
1237 };
1238 let v = serde_json::to_value(&res).unwrap();
1239 assert_eq!(v["ticket"], "blobAAA");
1240 assert_eq!(serde_json::from_value::<BlobPublishResult>(v).unwrap(), res);
1241
1242 // BlobScopeList carries flat (name, hashes, grants) — no EndpointId/key leakage.
1243 let res = BlobScopeList {
1244 scopes: vec![ScopeInfo {
1245 name: "docs".into(),
1246 hashes: vec!["ab".repeat(32)],
1247 grants: vec!["alice".into()],
1248 }],
1249 };
1250 let v = serde_json::to_value(&res).unwrap();
1251 assert_eq!(v["scopes"][0]["name"], "docs");
1252 assert_eq!(v["scopes"][0]["grants"][0], "alice");
1253 assert_eq!(serde_json::from_value::<BlobScopeList>(v).unwrap(), res);
1254
1255 // BlobFetchResult carries the verified hash + byte length.
1256 let res = BlobFetchResult {
1257 hash: "ab".repeat(32),
1258 bytes_len: 4194304,
1259 };
1260 let v = serde_json::to_value(&res).unwrap();
1261 assert_eq!(v["bytes_len"], 4194304u64);
1262 assert_eq!(serde_json::from_value::<BlobFetchResult>(v).unwrap(), res);
1263 }
1264
1265 /// The three `subscribe` frame shapes round-trip with the documented `type`-tagged wire form
1266 /// (docs/local-protocol.md "Live event stream"): `snapshot` carries the flat session/reachability
1267 /// lists, `event` delegates through the `Box` so the record's fields sit VERBATIM under
1268 /// `record` (one schema with the JSONL log), and `lagged` carries the dropped count.
1269 #[test]
1270 fn stream_frames_roundtrip_with_the_documented_tags() {
1271 let snap = StreamFrame::Snapshot {
1272 active_sessions: vec![ActiveSession {
1273 peer: "bob".into(),
1274 service: "notes".into(),
1275 opened_at: 1_751_760_000,
1276 }],
1277 reachability: vec![PeerReachability {
1278 name: "bob".into(),
1279 reachable: true,
1280 rtt_ms: Some(42),
1281 age_secs: Some(3),
1282 }],
1283 };
1284 let v = serde_json::to_value(&snap).unwrap();
1285 assert_eq!(v["type"], "snapshot");
1286 assert_eq!(v["active_sessions"][0]["peer"], "bob");
1287 assert_eq!(v["active_sessions"][0]["opened_at"], 1_751_760_000i64);
1288 assert_eq!(v["reachability"][0]["name"], "bob");
1289 assert_eq!(serde_json::from_value::<StreamFrame>(v).unwrap(), snap);
1290
1291 let event = StreamFrame::Event {
1292 record: Box::new(AuditRecord::session_open(
1293 "2026-07-03T14:02:11.480Z".into(),
1294 Some("bob".into()),
1295 "notes".into(),
1296 )),
1297 };
1298 let v = serde_json::to_value(&event).unwrap();
1299 assert_eq!(v["type"], "event");
1300 // The record's fields ride verbatim under `record` — no Box indirection on the wire.
1301 assert_eq!(v["record"]["kind"], "session_open");
1302 assert_eq!(v["record"]["peer"], "bob");
1303 assert_eq!(v["record"]["service"], "notes");
1304 assert_eq!(serde_json::from_value::<StreamFrame>(v).unwrap(), event);
1305
1306 let lagged = StreamFrame::Lagged { dropped: 12 };
1307 let v = serde_json::to_value(&lagged).unwrap();
1308 assert_eq!(v, serde_json::json!({ "type": "lagged", "dropped": 12 }));
1309 assert_eq!(serde_json::from_value::<StreamFrame>(v).unwrap(), lagged);
1310 }
1311
1312 /// A frame minted by a NEWER daemon (an unknown `type`) fails to deserialize rather than
1313 /// mis-parsing — the typed stream surface is closed; a forward-compatible consumer reads the
1314 /// raw `Value` stream instead (`ControlClient::open_stream`).
1315 #[test]
1316 fn unknown_stream_frame_type_is_rejected() {
1317 let future = serde_json::json!({ "type": "future_kind", "x": 1 });
1318 assert!(serde_json::from_value::<StreamFrame>(future).is_err());
1319 }
1320
1321 #[test]
1322 fn audit_summary_request_and_result_roundtrip() {
1323 // Request::AuditSummary is parameterless → `{ "method": "audit_summary" }`. Like Status, it
1324 // tolerates omitted/null params; the server dispatches on the method string (method_of).
1325 let r = Request::AuditSummary;
1326 assert_eq!(serde_json::to_value(&r).unwrap()["method"], "audit_summary");
1327 assert_eq!(
1328 method_of(&serde_json::json!({"method": "audit_summary"})),
1329 Some("audit_summary")
1330 );
1331
1332 // AuditSummaryResult carries LOCAL per-peer / per-service session counts (petnames + service
1333 // names only — never endpoints/transport terms) + a total. Tuples mirror kb's
1334 // InsightResponse.per_peer_contribution: `["bob", 2]` on the wire.
1335 let res = AuditSummaryResult {
1336 per_peer: vec![("alice".into(), 1), ("bob".into(), 2)],
1337 per_service: vec![("kb".into(), 1), ("notes".into(), 3)],
1338 total_sessions: 4,
1339 };
1340 let v = serde_json::to_value(&res).unwrap();
1341 assert_eq!(v["per_peer"][1][0], "bob");
1342 assert_eq!(v["per_peer"][1][1], 2u64);
1343 assert_eq!(v["per_service"][1][0], "notes");
1344 assert_eq!(v["total_sessions"], 4u64);
1345 assert_eq!(
1346 serde_json::from_value::<AuditSummaryResult>(v).unwrap(),
1347 res
1348 );
1349
1350 // Additive-only: a result minted by an older daemon (no `total_sessions` key) still
1351 // deserializes — the `#[serde(default)]` fills it with 0.
1352 let old_shape = serde_json::json!({ "per_peer": [], "per_service": [] });
1353 let back: AuditSummaryResult = serde_json::from_value(old_shape).unwrap();
1354 assert_eq!(back.total_sessions, 0);
1355 assert!(back.per_peer.is_empty());
1356 }
1357}