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}