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