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}