Skip to main content

recall_server/audit/
leaf.rs

1//! The audit leaf, version 1: one per authenticated push, pull, delete,
2//! and change to a device or authkey, and one per action the server
3//! takes itself. See `docs/design/part5-plan.md`'s "PR 1: audit" for the
4//! wire shape this mirrors exactly.
5//!
6//! **What is hashed** is the leaf exactly as written: compact JSON, fields
7//! in declaration order, UTF-8. [`encode`] is the one place that produces
8//! those bytes, so nothing downstream — the Merkle hash, storage, or an
9//! export — ever re-serializes a leaf; each holds or hashes what this wrote.
10//!
11//! Field order is built by hand with an ordered [`serde_json::Map`] rather
12//! than a `#[derive(Serialize)]` struct, because `subject` differs by
13//! `action` — a device's public key for `approve`, a project and file for
14//! `push` — and no single Rust type covers all of them without either a
15//! sea of `Option`s (each action's leaf would then carry visible nulls for
16//! every other action's fields) or an enum whose variant name would leak
17//! into the JSON as untagged data does not want it to. `serde_json`'s
18//! `preserve_order` feature, already enabled workspace-wide so a person's
19//! edited `.claude/settings.json` keeps its key order, is what makes the
20//! map's insertion order its serialization order.
21
22use serde_json::{json, Map, Value};
23
24/// The leaf format this server writes. Stored as the leaf's own `v` field,
25/// so a verifier reading an old export knows which rules applied.
26pub const VERSION: u32 = 1;
27
28/// The `action` values a leaf may carry.
29pub mod action {
30    /// A file was written (`POST /sync` with `deleted: false`).
31    pub const PUSH: &str = "push";
32    /// A file was tombstoned (`POST /sync` with `deleted: true`).
33    pub const DELETE: &str = "delete";
34    /// `GET /sync` — a client fetched a project's files.
35    pub const PULL: &str = "pull";
36    /// A pending enrolment was approved.
37    pub const APPROVE: &str = "approve";
38    /// A device enrolled with an authkey, approved at once by the key.
39    pub const ENROLL: &str = "enroll";
40    /// A pending enrolment was denied.
41    pub const DENY: &str = "deny";
42    /// A device was revoked.
43    pub const REVOKE: &str = "revoke";
44    /// The server removed an idle ephemeral device.
45    pub const SWEEP: &str = "sweep";
46    /// An authkey was created.
47    pub const AUTHKEY_CREATE: &str = "authkey_create";
48    /// An authkey was revoked.
49    pub const AUTHKEY_REVOKE: &str = "authkey_revoke";
50    /// The server started.
51    pub const START: &str = "start";
52    /// A worker, or the server draining the queue itself, leased a job.
53    pub const JOB_CLAIM: &str = "job_claim";
54    /// A job's result was recorded: a merge applied to its file, a
55    /// follow-up queued, or an attempt failed.
56    pub const JOB_RESULT: &str = "job_result";
57    /// A failed job was queued again.
58    pub const JOB_RETRY: &str = "job_retry";
59    /// An evaluation was asked for, by the owner or on the schedule
60    /// (`RECALL_EVAL_INTERVAL_HOURS`), and its `evaluate` job queued.
61    pub const EVALUATE: &str = "evaluate";
62    /// A passkey was registered for the admin page: the first, with
63    /// `RECALL_TOKEN` and the bootstrap code, or another, from a session.
64    pub const PASSKEY_ADD: &str = "passkey_add";
65    /// A passkey was removed, and every session it signed in with it.
66    pub const PASSKEY_REMOVE: &str = "passkey_remove";
67    /// Every admin session but the one asking was ended.
68    pub const SESSIONS_END: &str = "sessions_end";
69    /// The server issued a bootstrap code, at start, with no passkey
70    /// registered.
71    pub const BOOTSTRAP_CODE: &str = "bootstrap_code";
72    /// `recall-server reset-passkeys` removed every passkey and session and
73    /// issued a new bootstrap code.
74    pub const PASSKEY_RESET: &str = "passkey_reset";
75    /// `recall-server admin rename` moved every row of one project key to
76    /// another.
77    pub const ADMIN_RENAME: &str = "admin_rename";
78    /// `recall-server admin remove` deleted every row of one project key.
79    pub const ADMIN_REMOVE: &str = "admin_remove";
80    /// `recall-server admin restore` copied one project key's rows back from
81    /// a backup.
82    pub const ADMIN_RESTORE: &str = "admin_restore";
83}
84
85/// Who did it (`actor.kind`).
86pub enum Actor<'a> {
87    /// A signed request from an enrolled device.
88    Device {
89        /// `dev_…`.
90        id: &'a str,
91        /// The name it enrolled as.
92        name: &'a str,
93        /// Its `User-Agent`.
94        agent: &'a str,
95    },
96    /// `RECALL_TOKEN`.
97    Operator,
98    /// An authkey, which approves the device it enrols by itself: named by
99    /// its id and tag, never by the key.
100    Authkey {
101        /// `ak_…`.
102        id: &'a str,
103        /// Its label.
104        tag: &'a str,
105    },
106    /// The admin page's passkey session, named by the passkey it signed in
107    /// with, never by its token.
108    Session {
109        /// The passkey's credential id.
110        credential_id: &'a str,
111    },
112    /// The server's own doing: a sweep, `start`, a bootstrap code, or
113    /// settling a queued job itself once no worker is left to.
114    Server,
115    /// A command run where the server runs, on its database file:
116    /// `recall-server reset-passkeys`, and `recall-server admin`'s renames,
117    /// removes and restores.
118    Host,
119}
120
121impl Actor<'_> {
122    fn to_value(&self) -> Value {
123        let mut m = Map::new();
124        match self {
125            Actor::Device { id, name, agent } => {
126                m.insert("kind".into(), json!("device"));
127                m.insert("id".into(), json!(id));
128                m.insert("name".into(), json!(name));
129                m.insert("agent".into(), json!(agent));
130            }
131            Actor::Operator => {
132                m.insert("kind".into(), json!("operator"));
133            }
134            Actor::Authkey { id, tag } => {
135                m.insert("kind".into(), json!("authkey"));
136                m.insert("id".into(), json!(id));
137                m.insert("tag".into(), json!(tag));
138            }
139            Actor::Session { credential_id } => {
140                m.insert("kind".into(), json!("session"));
141                m.insert("credential_id".into(), json!(credential_id));
142            }
143            Actor::Server => {
144                m.insert("kind".into(), json!("server"));
145            }
146            Actor::Host => {
147                m.insert("kind".into(), json!("host"));
148            }
149        }
150        Value::Object(m)
151    }
152}
153
154/// The signed-request material a device-authenticated leaf carries in its
155/// `request` field: enough for an offline verifier to check the signature
156/// against nothing but the device's key, itself carried by an earlier
157/// `approve` or `enroll` leaf (see `docs/design/part5-plan.md`'s
158/// "Verifying offline").
159///
160/// What a signature proves is that the device sent a request with this
161/// method, path, query and body digest — not what the server made of it.
162/// The path and query bind a pull's project and a revoke's id; `body`
163/// binds the rest of a device-management action, whose small body is kept
164/// whole. A push's or a delete's body is the file, or names it, and is not
165/// kept, so for those the signature vouches for the request and its
166/// digest, and `subject` is the server's word.
167pub struct SignedRequest<'a> {
168    /// `Content-Digest`'s sha-256 value, base64 standard: the hash of the
169    /// request body — for a push, the JSON that carried the file, not the
170    /// file.
171    pub body_sha256: &'a str,
172    /// The exact bytes RFC 9421 §2.5 built and the device signed.
173    pub signature_base: &'a str,
174    /// The signature itself, base64 standard.
175    pub signature: &'a str,
176    /// The request body itself, for the actions whose body is a few bytes
177    /// of JSON with no secret in it: `approve`, `deny`, `revoke`,
178    /// `authkey_create`, `authkey_revoke` and `evaluate`. [`None`], written `null`, for
179    /// a push, a delete, a pull and the job actions.
180    pub body: Option<&'a str>,
181}
182
183impl SignedRequest<'_> {
184    fn to_value(&self) -> Value {
185        let mut m = Map::new();
186        m.insert("body_sha256".into(), json!(self.body_sha256));
187        m.insert("signature_base".into(), json!(self.signature_base));
188        m.insert("signature".into(), json!(self.signature));
189        m.insert("body".into(), json!(self.body));
190        Value::Object(m)
191    }
192}
193
194/// Encodes one leaf as [`VERSION`]'s compact JSON, in the field order the
195/// design shows: `v`, `seq`, `at`, `action`, `actor`, `subject`, `request`.
196///
197/// This is the exact byte sequence that gets hashed, stored, and exported —
198/// see the module docs.
199pub fn encode(
200    seq: u64,
201    at: &str,
202    action: &str,
203    actor: &Actor<'_>,
204    subject: Value,
205    request: Option<&SignedRequest<'_>>,
206) -> Vec<u8> {
207    let mut m = Map::new();
208    m.insert("v".into(), json!(VERSION));
209    m.insert("seq".into(), json!(seq));
210    m.insert("at".into(), json!(at));
211    m.insert("action".into(), json!(action));
212    m.insert("actor".into(), actor.to_value());
213    m.insert("subject".into(), subject);
214    m.insert(
215        "request".into(),
216        request.map(SignedRequest::to_value).unwrap_or(Value::Null),
217    );
218    // A leaf is never pretty-printed or re-ordered: `to_string` is
219    // serde_json's compact writer, and the map above preserves insertion
220    // order (`preserve_order`), so this is deterministic input for
221    // `merkle::hash_leaf`.
222    serde_json::to_vec(&Value::Object(m)).expect("a leaf built from valid JSON values serializes")
223}
224
225/// What a push or a delete changed, for [`subject_file`].
226pub struct FileChange<'a> {
227    /// The project.
228    pub project_key: &'a str,
229    /// The file, relative to the project's memory directory.
230    pub file_path: &'a str,
231    /// A delete, rather than a push.
232    pub deleted: bool,
233    /// `content_sha256` of what is stored now; for a delete, of the empty
234    /// string.
235    pub stored_sha256: &'a str,
236    /// The `base_sha256` the push named, lowercase, if it named one.
237    pub base_sha256: Option<&'a str>,
238    /// Whether the server merged the push with what it held, so what it
239    /// stored is not what was sent, and `stored_sha256` is not the hash of
240    /// the pushed content.
241    pub merged: bool,
242    /// The job that will merge this push with the version it displaced,
243    /// when it was queued for a worker rather than merged here.
244    pub merge_job: Option<&'a str>,
245}
246
247/// `subject` for [`action::PUSH`] and [`action::DELETE`]: a file's identity
248/// and the hash of what is now stored, never the content.
249///
250/// Two kinds of merge show here. `merged` is the one done inline before
251/// storing; `merge_job` names the job a push was queued for instead, whose
252/// own `job_result` leaf records what the worker made of it.
253pub fn subject_file(change: &FileChange<'_>) -> Value {
254    let mut m = Map::new();
255    m.insert("project_key".into(), json!(change.project_key));
256    m.insert("file_path".into(), json!(change.file_path));
257    m.insert("deleted".into(), json!(change.deleted));
258    m.insert("stored_sha256".into(), json!(change.stored_sha256));
259    m.insert("base_sha256".into(), json!(change.base_sha256));
260    m.insert("merged".into(), json!(change.merged));
261    m.insert("merge_job".into(), json!(change.merge_job));
262    Value::Object(m)
263}
264
265/// `subject` for [`action::JOB_CLAIM`]: the job leased, which attempt this
266/// is and until when, and, for a merge, its file. Never the lease id, which
267/// is what a result is posted under.
268pub fn subject_job_claim(job: &recall_wire::Job) -> Value {
269    let mut m = Map::new();
270    m.insert("job_id".into(), json!(job.id));
271    m.insert("kind".into(), json!(job.kind));
272    m.insert("attempt".into(), json!(job.attempt));
273    m.insert("lease_expires_at".into(), json!(job.lease_expires_at));
274    m.insert(
275        "project_key".into(),
276        json!(job.merge.as_ref().map(|i| &i.project_key)),
277    );
278    m.insert(
279        "file_path".into(),
280        json!(job.merge.as_ref().map(|i| &i.file_path)),
281    );
282    Value::Object(m)
283}
284
285/// What a recorded result changed, for [`subject_job_result`].
286pub struct JobChange<'a> {
287    /// The job.
288    pub job_id: &'a str,
289    /// Its file's project.
290    pub project_key: &'a str,
291    /// Its file.
292    pub file_path: &'a str,
293    /// Its state now: `done`, `queued` for another attempt, or `failed`.
294    pub state: &'a str,
295    /// `content_sha256` of the merged content, when this result wrote it to
296    /// the file; [`None`] when the file was left as it was.
297    pub stored_sha256: Option<&'a str>,
298    /// The job that merges this result with a newer version, when the
299    /// file changed while it ran.
300    pub follow_up: Option<&'a str>,
301}
302
303/// `subject` for [`action::JOB_RESULT`]. A result's body is the merged
304/// file, so it is not kept; `stored_sha256` says what, if anything, the
305/// file became.
306pub fn subject_job_result(change: &JobChange<'_>) -> Value {
307    let mut m = Map::new();
308    m.insert("job_id".into(), json!(change.job_id));
309    m.insert("project_key".into(), json!(change.project_key));
310    m.insert("file_path".into(), json!(change.file_path));
311    m.insert("state".into(), json!(change.state));
312    m.insert("stored_sha256".into(), json!(change.stored_sha256));
313    m.insert("follow_up".into(), json!(change.follow_up));
314    Value::Object(m)
315}
316
317/// `subject` for [`action::JOB_RETRY`].
318pub fn subject_job_retry(job: &recall_wire::JobSummary) -> Value {
319    let mut m = Map::new();
320    m.insert("job_id".into(), json!(job.id));
321    m.insert("kind".into(), json!(job.kind));
322    m.insert("project_key".into(), json!(job.project_key));
323    m.insert("file_path".into(), json!(job.file_path));
324    Value::Object(m)
325}
326
327/// `subject` for [`action::PASSKEY_ADD`] and [`action::PASSKEY_REMOVE`]:
328/// which passkey, by its credential id and name, and, when added, whether
329/// it was the first, registered with the bootstrap code. Never the key
330/// itself: a passkey signs in to the admin page, not requests this log
331/// checks.
332pub fn subject_passkey(credential_id: &str, name: &str, first: Option<bool>) -> Value {
333    let mut m = Map::new();
334    m.insert("credential_id".into(), json!(credential_id));
335    m.insert("name".into(), json!(name));
336    if let Some(first) = first {
337        m.insert("first".into(), json!(first));
338    }
339    Value::Object(m)
340}
341
342/// `subject` for [`action::SESSIONS_END`]: how many sessions ended.
343pub fn subject_sessions_end(ended: usize) -> Value {
344    let mut m = Map::new();
345    m.insert("ended".into(), json!(ended));
346    Value::Object(m)
347}
348
349/// `subject` for [`action::BOOTSTRAP_CODE`], and with `removed` for
350/// [`action::PASSKEY_RESET`]: until when the code holds, and how many
351/// passkeys went. Never the code, nor its hash, which a code this short
352/// would not survive.
353pub fn subject_bootstrap(removed: Option<usize>, expires_at: &str) -> Value {
354    let mut m = Map::new();
355    if let Some(removed) = removed {
356        m.insert("passkeys_removed".into(), json!(removed));
357    }
358    m.insert("expires_at".into(), json!(expires_at));
359    Value::Object(m)
360}
361
362/// What a `recall-server admin` change did, for [`subject_admin`].
363///
364/// Counts and names only: never a file's content, nor even its path, which
365/// the backup the change took holds, with everything else that was there.
366pub enum AdminChange<'a> {
367    /// [`action::ADMIN_RENAME`].
368    Rename {
369        /// The key the rows were under.
370        from: &'a str,
371        /// The key they are under now.
372        to: &'a str,
373        /// How many rows moved, tombstones included.
374        rows: usize,
375    },
376    /// [`action::ADMIN_REMOVE`].
377    Remove {
378        /// The key whose rows went.
379        project_key: &'a str,
380        /// How many rows went, tombstones included.
381        rows: usize,
382    },
383    /// [`action::ADMIN_RESTORE`].
384    Restore {
385        /// The key restored.
386        project_key: &'a str,
387        /// The file name of the backup restored from, without its directory.
388        source: &'a str,
389        /// Rows the live database lacked, inserted.
390        added: usize,
391        /// Live rows replaced with the backup's version.
392        overwritten: usize,
393        /// Live files replaced with the backup's tombstone
394        /// (`--restore-deletions`).
395        deleted: usize,
396    },
397}
398
399impl AdminChange<'_> {
400    /// Its [`action`].
401    pub fn action(&self) -> &'static str {
402        match self {
403            AdminChange::Rename { .. } => action::ADMIN_RENAME,
404            AdminChange::Remove { .. } => action::ADMIN_REMOVE,
405            AdminChange::Restore { .. } => action::ADMIN_RESTORE,
406        }
407    }
408}
409
410/// `subject` for [`action::ADMIN_RENAME`], [`action::ADMIN_REMOVE`] and
411/// [`action::ADMIN_RESTORE`]: the keys, how many rows changed, the ids of
412/// the open jobs the change closed (whose results would otherwise have
413/// landed on rows it moved, removed or replaced), and the file name of the
414/// backup it took first, never its content.
415pub fn subject_admin(change: &AdminChange<'_>, jobs_closed: &[String], backup: &str) -> Value {
416    let mut m = Map::new();
417    match change {
418        AdminChange::Rename { from, to, rows } => {
419            m.insert("from".into(), json!(from));
420            m.insert("to".into(), json!(to));
421            m.insert("rows".into(), json!(rows));
422        }
423        AdminChange::Remove { project_key, rows } => {
424            m.insert("project_key".into(), json!(project_key));
425            m.insert("rows".into(), json!(rows));
426        }
427        AdminChange::Restore {
428            project_key,
429            source,
430            added,
431            overwritten,
432            deleted,
433        } => {
434            m.insert("project_key".into(), json!(project_key));
435            m.insert("source".into(), json!(source));
436            m.insert("added".into(), json!(added));
437            m.insert("overwritten".into(), json!(overwritten));
438            m.insert("deleted".into(), json!(deleted));
439        }
440    }
441    m.insert("jobs_closed".into(), json!(jobs_closed));
442    m.insert("backup".into(), json!(backup));
443    Value::Object(m)
444}
445
446/// `subject` for [`action::PULL`]: which project was fetched.
447pub fn subject_pull(project_key: &str) -> Value {
448    let mut m = Map::new();
449    m.insert("project_key".into(), json!(project_key));
450    Value::Object(m)
451}
452
453/// `subject` for [`action::APPROVE`] and [`action::ENROLL`], the two ways a
454/// device comes to exist: its own identity and public key, so a signature
455/// it made can still be checked after the device row itself is gone
456/// (revoked, or swept). One shape for both: `authkey_id` names the key an
457/// `enroll` came in with and `user_code` the code an `approve` decided,
458/// each `null` in the other.
459pub fn subject_device(device: &recall_wire::Device, user_code: Option<&str>) -> Value {
460    let mut m = Map::new();
461    m.insert("device_id".into(), json!(device.id));
462    m.insert("name".into(), json!(device.name));
463    m.insert("scope".into(), json!(device.scope));
464    m.insert("public_key".into(), json!(device.public_key));
465    m.insert("fingerprint".into(), json!(device.fingerprint));
466    m.insert("ephemeral".into(), json!(device.ephemeral));
467    m.insert("authkey_id".into(), json!(device.authkey_id));
468    m.insert("user_code".into(), json!(user_code));
469    Value::Object(m)
470}
471
472/// `subject` for [`action::REVOKE`] and [`action::SWEEP`]: which device,
473/// without needing its key again — the `approve` or `enroll` leaf that made
474/// it already carries that.
475pub fn subject_device_id(device_id: &str, name: &str) -> Value {
476    let mut m = Map::new();
477    m.insert("device_id".into(), json!(device_id));
478    m.insert("name".into(), json!(name));
479    Value::Object(m)
480}
481
482/// `subject` for [`action::DENY`]: the code and the name it would have
483/// taken. No device exists to name by id.
484pub fn subject_denied(user_code: &str, name: &str) -> Value {
485    let mut m = Map::new();
486    m.insert("user_code".into(), json!(user_code));
487    m.insert("name".into(), json!(name));
488    Value::Object(m)
489}
490
491/// `subject` for [`action::AUTHKEY_CREATE`]: the key's metadata, never
492/// the secret itself — which the server never stores past the one response
493/// that shows it.
494pub fn subject_authkey(id: &str, tag: &str, ephemeral: bool, max_devices: Option<u32>) -> Value {
495    let mut m = Map::new();
496    m.insert("authkey_id".into(), json!(id));
497    m.insert("tag".into(), json!(tag));
498    m.insert("ephemeral".into(), json!(ephemeral));
499    m.insert("max_devices".into(), json!(max_devices));
500    Value::Object(m)
501}
502
503/// `subject` for [`action::AUTHKEY_REVOKE`]: the key, whether the request
504/// asked for its devices too, and the devices this revoked — the ones it
505/// enrolled that were not revoked already — so the log names every device
506/// that stopped here, though none of them has a leaf of its own.
507pub fn subject_authkey_revoke(id: &str, revoke_devices: bool, revoked: &[String]) -> Value {
508    let mut m = Map::new();
509    m.insert("authkey_id".into(), json!(id));
510    m.insert("revoke_devices".into(), json!(revoke_devices));
511    m.insert("revoked_devices".into(), json!(revoked));
512    Value::Object(m)
513}
514
515/// `subject` for [`action::EVALUATE`]: the run, the job that makes it,
516/// and what was asked: the projects (empty for every project) and whether
517/// to run the contradiction check. Nothing of what the run will read.
518pub fn subject_evaluate(
519    evaluation_id: &str,
520    job_id: &str,
521    projects: &[String],
522    contradictions: bool,
523) -> Value {
524    let mut m = Map::new();
525    m.insert("evaluation_id".into(), json!(evaluation_id));
526    m.insert("job_id".into(), json!(job_id));
527    m.insert("projects".into(), json!(projects));
528    m.insert("contradictions".into(), json!(contradictions));
529    Value::Object(m)
530}
531
532/// `subject` for [`action::START`]: the version the server started as.
533pub fn subject_start(version: &str) -> Value {
534    let mut m = Map::new();
535    m.insert("version".into(), json!(version));
536    Value::Object(m)
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    /// The exact field order and compactness the design shows, so a
544    /// verifier hashing "what it was given" is hashing what this wrote.
545    #[test]
546    fn a_push_leaf_has_the_documented_shape() {
547        let bytes = encode(
548            1001,
549            "2026-10-02T09:14:05.402Z",
550            action::PUSH,
551            &Actor::Device {
552                id: "dev_eerivjyffuwecbgzybcesz5hwi",
553                name: "laptop",
554                agent: "recall/0.4.5 (macos-aarch64)",
555            },
556            subject_file(&FileChange {
557                project_key: "acme/app",
558                file_path: "topics/auth.md",
559                deleted: false,
560                stored_sha256: "4b1f",
561                base_sha256: Some("9f2c"),
562                merged: true,
563                merge_job: None,
564            }),
565            Some(&SignedRequest {
566                body_sha256: "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
567                signature_base: "\"@method\": POST\n",
568                signature: "sig",
569                body: None,
570            }),
571        );
572        let text = String::from_utf8(bytes).unwrap();
573        assert_eq!(
574            text,
575            concat!(
576                r#"{"v":1,"seq":1001,"at":"2026-10-02T09:14:05.402Z","action":"push","#,
577                r#""actor":{"kind":"device","id":"dev_eerivjyffuwecbgzybcesz5hwi","name":"laptop","agent":"recall/0.4.5 (macos-aarch64)"},"#,
578                r#""subject":{"project_key":"acme/app","file_path":"topics/auth.md","deleted":false,"stored_sha256":"4b1f","base_sha256":"9f2c","merged":true,"merge_job":null},"#,
579                r#""request":{"body_sha256":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","signature_base":"\"@method\": POST\n","signature":"sig","body":null}}"#,
580            ),
581            "got {text}"
582        );
583        // Compact, one line: a real newline inside a field is escaped, not
584        // left to break the export's one-leaf-per-line shape.
585        assert!(!text.contains('\n'), "got {text}");
586    }
587
588    /// The two leaves that make a device, `approve` and `enroll`, share one
589    /// subject: the public key a later signature is checked with, and
590    /// whichever of the code or the authkey it came by.
591    #[test]
592    fn an_enroll_leaf_names_the_authkey_and_carries_the_key() {
593        let device = recall_wire::Device {
594            id: "dev_x".into(),
595            name: "cloud-k3jz9w2q".into(),
596            scope: "sync".into(),
597            ephemeral: true,
598            public_key: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs".into(),
599            fingerprint: "SHA256:fp".into(),
600            authkey_id: Some("ak_1".into()),
601            ..Default::default()
602        };
603        let text = String::from_utf8(encode(
604            7,
605            "2026-10-02T09:00:00.000Z",
606            action::ENROLL,
607            &Actor::Authkey {
608                id: "ak_1",
609                tag: "cloud",
610            },
611            subject_device(&device, None),
612            None,
613        ))
614        .unwrap();
615        assert_eq!(
616            text,
617            concat!(
618                r#"{"v":1,"seq":7,"at":"2026-10-02T09:00:00.000Z","action":"enroll","#,
619                r#""actor":{"kind":"authkey","id":"ak_1","tag":"cloud"},"#,
620                r#""subject":{"device_id":"dev_x","name":"cloud-k3jz9w2q","scope":"sync","#,
621                r#""public_key":"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs","fingerprint":"SHA256:fp","#,
622                r#""ephemeral":true,"authkey_id":"ak_1","user_code":null},"request":null}"#,
623            )
624        );
625    }
626
627    /// An admin change's leaf names the keys, the counts, the jobs it
628    /// closed and its backup's file name, the host acting and signing
629    /// nothing.
630    #[test]
631    fn an_admin_leaf_has_the_documented_shape() {
632        let jobs = vec!["job_a".to_string()];
633        let text = |change: AdminChange<'_>| {
634            String::from_utf8(encode(
635                3,
636                "2026-10-02T09:00:00.000Z",
637                change.action(),
638                &Actor::Host,
639                subject_admin(&change, &jobs, "recall-2026-10-02T08-59-58-120Z.db"),
640                None,
641            ))
642            .unwrap()
643        };
644        assert_eq!(
645            text(AdminChange::Rename {
646                from: "local:-x",
647                to: "me/x",
648                rows: 3
649            }),
650            concat!(
651                r#"{"v":1,"seq":3,"at":"2026-10-02T09:00:00.000Z","action":"admin_rename","#,
652                r#""actor":{"kind":"host"},"subject":{"from":"local:-x","to":"me/x","rows":3,"#,
653                r#""jobs_closed":["job_a"],"backup":"recall-2026-10-02T08-59-58-120Z.db"},"request":null}"#,
654            )
655        );
656        let remove = text(AdminChange::Remove {
657            project_key: "me/x",
658            rows: 2,
659        });
660        assert!(
661            remove.contains(concat!(
662                r#""action":"admin_remove","actor":{"kind":"host"},"#,
663                r#""subject":{"project_key":"me/x","rows":2,"jobs_closed":["job_a"],"#
664            )),
665            "{remove}"
666        );
667        let restore = text(AdminChange::Restore {
668            project_key: "me/x",
669            source: "recall-1.db",
670            added: 1,
671            overwritten: 2,
672            deleted: 0,
673        });
674        assert!(
675            restore.contains(concat!(
676                r#""subject":{"project_key":"me/x","source":"recall-1.db","added":1,"#,
677                r#""overwritten":2,"deleted":0,"jobs_closed":["job_a"],"backup":"#
678            )),
679            "{restore}"
680        );
681    }
682
683    /// A leaf with no signed request (operator or server actor) carries
684    /// `request: null`, not an omitted field — consistent with the rest of
685    /// the API's null-versus-absent rule.
686    #[test]
687    fn an_unsigned_leaf_carries_request_null() {
688        let bytes = encode(
689            0,
690            "2026-10-02T09:00:00.000Z",
691            action::START,
692            &Actor::Server,
693            subject_start("0.4.1"),
694            None,
695        );
696        let text = String::from_utf8(bytes).unwrap();
697        assert!(text.contains(r#""request":null"#), "got {text}");
698        assert!(text.contains(r#""actor":{"kind":"server"}"#), "got {text}");
699    }
700}