use serde_json::{json, Map, Value};
pub const VERSION: u32 = 1;
pub mod action {
pub const PUSH: &str = "push";
pub const DELETE: &str = "delete";
pub const PULL: &str = "pull";
pub const APPROVE: &str = "approve";
pub const ENROLL: &str = "enroll";
pub const DENY: &str = "deny";
pub const REVOKE: &str = "revoke";
pub const SWEEP: &str = "sweep";
pub const AUTHKEY_CREATE: &str = "authkey_create";
pub const AUTHKEY_REVOKE: &str = "authkey_revoke";
pub const START: &str = "start";
pub const JOB_CLAIM: &str = "job_claim";
pub const JOB_RESULT: &str = "job_result";
pub const JOB_RETRY: &str = "job_retry";
pub const PASSKEY_ADD: &str = "passkey_add";
pub const PASSKEY_REMOVE: &str = "passkey_remove";
pub const SESSIONS_END: &str = "sessions_end";
pub const BOOTSTRAP_CODE: &str = "bootstrap_code";
pub const PASSKEY_RESET: &str = "passkey_reset";
pub const ADMIN_RENAME: &str = "admin_rename";
pub const ADMIN_REMOVE: &str = "admin_remove";
pub const ADMIN_RESTORE: &str = "admin_restore";
}
pub enum Actor<'a> {
Device {
id: &'a str,
name: &'a str,
agent: &'a str,
},
Operator,
Authkey {
id: &'a str,
tag: &'a str,
},
Session {
credential_id: &'a str,
},
Server,
Host,
}
impl Actor<'_> {
fn to_value(&self) -> Value {
let mut m = Map::new();
match self {
Actor::Device { id, name, agent } => {
m.insert("kind".into(), json!("device"));
m.insert("id".into(), json!(id));
m.insert("name".into(), json!(name));
m.insert("agent".into(), json!(agent));
}
Actor::Operator => {
m.insert("kind".into(), json!("operator"));
}
Actor::Authkey { id, tag } => {
m.insert("kind".into(), json!("authkey"));
m.insert("id".into(), json!(id));
m.insert("tag".into(), json!(tag));
}
Actor::Session { credential_id } => {
m.insert("kind".into(), json!("session"));
m.insert("credential_id".into(), json!(credential_id));
}
Actor::Server => {
m.insert("kind".into(), json!("server"));
}
Actor::Host => {
m.insert("kind".into(), json!("host"));
}
}
Value::Object(m)
}
}
pub struct SignedRequest<'a> {
pub body_sha256: &'a str,
pub signature_base: &'a str,
pub signature: &'a str,
pub body: Option<&'a str>,
}
impl SignedRequest<'_> {
fn to_value(&self) -> Value {
let mut m = Map::new();
m.insert("body_sha256".into(), json!(self.body_sha256));
m.insert("signature_base".into(), json!(self.signature_base));
m.insert("signature".into(), json!(self.signature));
m.insert("body".into(), json!(self.body));
Value::Object(m)
}
}
pub fn encode(
seq: u64,
at: &str,
action: &str,
actor: &Actor<'_>,
subject: Value,
request: Option<&SignedRequest<'_>>,
) -> Vec<u8> {
let mut m = Map::new();
m.insert("v".into(), json!(VERSION));
m.insert("seq".into(), json!(seq));
m.insert("at".into(), json!(at));
m.insert("action".into(), json!(action));
m.insert("actor".into(), actor.to_value());
m.insert("subject".into(), subject);
m.insert(
"request".into(),
request.map(SignedRequest::to_value).unwrap_or(Value::Null),
);
serde_json::to_vec(&Value::Object(m)).expect("a leaf built from valid JSON values serializes")
}
pub struct FileChange<'a> {
pub project_key: &'a str,
pub file_path: &'a str,
pub deleted: bool,
pub stored_sha256: &'a str,
pub base_sha256: Option<&'a str>,
pub merged: bool,
pub merge_job: Option<&'a str>,
}
pub fn subject_file(change: &FileChange<'_>) -> Value {
let mut m = Map::new();
m.insert("project_key".into(), json!(change.project_key));
m.insert("file_path".into(), json!(change.file_path));
m.insert("deleted".into(), json!(change.deleted));
m.insert("stored_sha256".into(), json!(change.stored_sha256));
m.insert("base_sha256".into(), json!(change.base_sha256));
m.insert("merged".into(), json!(change.merged));
m.insert("merge_job".into(), json!(change.merge_job));
Value::Object(m)
}
pub fn subject_job_claim(job: &recall_wire::Job) -> Value {
let mut m = Map::new();
m.insert("job_id".into(), json!(job.id));
m.insert("kind".into(), json!(job.kind));
m.insert("attempt".into(), json!(job.attempt));
m.insert("lease_expires_at".into(), json!(job.lease_expires_at));
m.insert(
"project_key".into(),
json!(job.merge.as_ref().map(|i| &i.project_key)),
);
m.insert(
"file_path".into(),
json!(job.merge.as_ref().map(|i| &i.file_path)),
);
Value::Object(m)
}
pub struct JobChange<'a> {
pub job_id: &'a str,
pub project_key: &'a str,
pub file_path: &'a str,
pub state: &'a str,
pub stored_sha256: Option<&'a str>,
pub follow_up: Option<&'a str>,
}
pub fn subject_job_result(change: &JobChange<'_>) -> Value {
let mut m = Map::new();
m.insert("job_id".into(), json!(change.job_id));
m.insert("project_key".into(), json!(change.project_key));
m.insert("file_path".into(), json!(change.file_path));
m.insert("state".into(), json!(change.state));
m.insert("stored_sha256".into(), json!(change.stored_sha256));
m.insert("follow_up".into(), json!(change.follow_up));
Value::Object(m)
}
pub fn subject_job_retry(job: &recall_wire::JobSummary) -> Value {
let mut m = Map::new();
m.insert("job_id".into(), json!(job.id));
m.insert("kind".into(), json!(job.kind));
m.insert("project_key".into(), json!(job.project_key));
m.insert("file_path".into(), json!(job.file_path));
Value::Object(m)
}
pub fn subject_passkey(credential_id: &str, name: &str, first: Option<bool>) -> Value {
let mut m = Map::new();
m.insert("credential_id".into(), json!(credential_id));
m.insert("name".into(), json!(name));
if let Some(first) = first {
m.insert("first".into(), json!(first));
}
Value::Object(m)
}
pub fn subject_sessions_end(ended: usize) -> Value {
let mut m = Map::new();
m.insert("ended".into(), json!(ended));
Value::Object(m)
}
pub fn subject_bootstrap(removed: Option<usize>, expires_at: &str) -> Value {
let mut m = Map::new();
if let Some(removed) = removed {
m.insert("passkeys_removed".into(), json!(removed));
}
m.insert("expires_at".into(), json!(expires_at));
Value::Object(m)
}
pub enum AdminChange<'a> {
Rename {
from: &'a str,
to: &'a str,
rows: usize,
},
Remove {
project_key: &'a str,
rows: usize,
},
Restore {
project_key: &'a str,
source: &'a str,
added: usize,
overwritten: usize,
deleted: usize,
},
}
impl AdminChange<'_> {
pub fn action(&self) -> &'static str {
match self {
AdminChange::Rename { .. } => action::ADMIN_RENAME,
AdminChange::Remove { .. } => action::ADMIN_REMOVE,
AdminChange::Restore { .. } => action::ADMIN_RESTORE,
}
}
}
pub fn subject_admin(change: &AdminChange<'_>, jobs_closed: &[String], backup: &str) -> Value {
let mut m = Map::new();
match change {
AdminChange::Rename { from, to, rows } => {
m.insert("from".into(), json!(from));
m.insert("to".into(), json!(to));
m.insert("rows".into(), json!(rows));
}
AdminChange::Remove { project_key, rows } => {
m.insert("project_key".into(), json!(project_key));
m.insert("rows".into(), json!(rows));
}
AdminChange::Restore {
project_key,
source,
added,
overwritten,
deleted,
} => {
m.insert("project_key".into(), json!(project_key));
m.insert("source".into(), json!(source));
m.insert("added".into(), json!(added));
m.insert("overwritten".into(), json!(overwritten));
m.insert("deleted".into(), json!(deleted));
}
}
m.insert("jobs_closed".into(), json!(jobs_closed));
m.insert("backup".into(), json!(backup));
Value::Object(m)
}
pub fn subject_pull(project_key: &str) -> Value {
let mut m = Map::new();
m.insert("project_key".into(), json!(project_key));
Value::Object(m)
}
pub fn subject_device(device: &recall_wire::Device, user_code: Option<&str>) -> Value {
let mut m = Map::new();
m.insert("device_id".into(), json!(device.id));
m.insert("name".into(), json!(device.name));
m.insert("scope".into(), json!(device.scope));
m.insert("public_key".into(), json!(device.public_key));
m.insert("fingerprint".into(), json!(device.fingerprint));
m.insert("ephemeral".into(), json!(device.ephemeral));
m.insert("authkey_id".into(), json!(device.authkey_id));
m.insert("user_code".into(), json!(user_code));
Value::Object(m)
}
pub fn subject_device_id(device_id: &str, name: &str) -> Value {
let mut m = Map::new();
m.insert("device_id".into(), json!(device_id));
m.insert("name".into(), json!(name));
Value::Object(m)
}
pub fn subject_denied(user_code: &str, name: &str) -> Value {
let mut m = Map::new();
m.insert("user_code".into(), json!(user_code));
m.insert("name".into(), json!(name));
Value::Object(m)
}
pub fn subject_authkey(id: &str, tag: &str, ephemeral: bool, max_devices: Option<u32>) -> Value {
let mut m = Map::new();
m.insert("authkey_id".into(), json!(id));
m.insert("tag".into(), json!(tag));
m.insert("ephemeral".into(), json!(ephemeral));
m.insert("max_devices".into(), json!(max_devices));
Value::Object(m)
}
pub fn subject_authkey_revoke(id: &str, revoke_devices: bool, revoked: &[String]) -> Value {
let mut m = Map::new();
m.insert("authkey_id".into(), json!(id));
m.insert("revoke_devices".into(), json!(revoke_devices));
m.insert("revoked_devices".into(), json!(revoked));
Value::Object(m)
}
pub fn subject_start(version: &str) -> Value {
let mut m = Map::new();
m.insert("version".into(), json!(version));
Value::Object(m)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_push_leaf_has_the_documented_shape() {
let bytes = encode(
1001,
"2026-10-02T09:14:05.402Z",
action::PUSH,
&Actor::Device {
id: "dev_eerivjyffuwecbgzybcesz5hwi",
name: "laptop",
agent: "recall/0.4.5 (macos-aarch64)",
},
subject_file(&FileChange {
project_key: "acme/app",
file_path: "topics/auth.md",
deleted: false,
stored_sha256: "4b1f",
base_sha256: Some("9f2c"),
merged: true,
merge_job: None,
}),
Some(&SignedRequest {
body_sha256: "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
signature_base: "\"@method\": POST\n",
signature: "sig",
body: None,
}),
);
let text = String::from_utf8(bytes).unwrap();
assert_eq!(
text,
concat!(
r#"{"v":1,"seq":1001,"at":"2026-10-02T09:14:05.402Z","action":"push","#,
r#""actor":{"kind":"device","id":"dev_eerivjyffuwecbgzybcesz5hwi","name":"laptop","agent":"recall/0.4.5 (macos-aarch64)"},"#,
r#""subject":{"project_key":"acme/app","file_path":"topics/auth.md","deleted":false,"stored_sha256":"4b1f","base_sha256":"9f2c","merged":true,"merge_job":null},"#,
r#""request":{"body_sha256":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","signature_base":"\"@method\": POST\n","signature":"sig","body":null}}"#,
),
"got {text}"
);
assert!(!text.contains('\n'), "got {text}");
}
#[test]
fn an_enroll_leaf_names_the_authkey_and_carries_the_key() {
let device = recall_wire::Device {
id: "dev_x".into(),
name: "cloud-k3jz9w2q".into(),
scope: "sync".into(),
ephemeral: true,
public_key: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs".into(),
fingerprint: "SHA256:fp".into(),
authkey_id: Some("ak_1".into()),
..Default::default()
};
let text = String::from_utf8(encode(
7,
"2026-10-02T09:00:00.000Z",
action::ENROLL,
&Actor::Authkey {
id: "ak_1",
tag: "cloud",
},
subject_device(&device, None),
None,
))
.unwrap();
assert_eq!(
text,
concat!(
r#"{"v":1,"seq":7,"at":"2026-10-02T09:00:00.000Z","action":"enroll","#,
r#""actor":{"kind":"authkey","id":"ak_1","tag":"cloud"},"#,
r#""subject":{"device_id":"dev_x","name":"cloud-k3jz9w2q","scope":"sync","#,
r#""public_key":"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs","fingerprint":"SHA256:fp","#,
r#""ephemeral":true,"authkey_id":"ak_1","user_code":null},"request":null}"#,
)
);
}
#[test]
fn an_admin_leaf_has_the_documented_shape() {
let jobs = vec!["job_a".to_string()];
let text = |change: AdminChange<'_>| {
String::from_utf8(encode(
3,
"2026-10-02T09:00:00.000Z",
change.action(),
&Actor::Host,
subject_admin(&change, &jobs, "recall-2026-10-02T08-59-58-120Z.db"),
None,
))
.unwrap()
};
assert_eq!(
text(AdminChange::Rename {
from: "local:-x",
to: "me/x",
rows: 3
}),
concat!(
r#"{"v":1,"seq":3,"at":"2026-10-02T09:00:00.000Z","action":"admin_rename","#,
r#""actor":{"kind":"host"},"subject":{"from":"local:-x","to":"me/x","rows":3,"#,
r#""jobs_closed":["job_a"],"backup":"recall-2026-10-02T08-59-58-120Z.db"},"request":null}"#,
)
);
let remove = text(AdminChange::Remove {
project_key: "me/x",
rows: 2,
});
assert!(
remove.contains(concat!(
r#""action":"admin_remove","actor":{"kind":"host"},"#,
r#""subject":{"project_key":"me/x","rows":2,"jobs_closed":["job_a"],"#
)),
"{remove}"
);
let restore = text(AdminChange::Restore {
project_key: "me/x",
source: "recall-1.db",
added: 1,
overwritten: 2,
deleted: 0,
});
assert!(
restore.contains(concat!(
r#""subject":{"project_key":"me/x","source":"recall-1.db","added":1,"#,
r#""overwritten":2,"deleted":0,"jobs_closed":["job_a"],"backup":"#
)),
"{restore}"
);
}
#[test]
fn an_unsigned_leaf_carries_request_null() {
let bytes = encode(
0,
"2026-10-02T09:00:00.000Z",
action::START,
&Actor::Server,
subject_start("0.4.1"),
None,
);
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains(r#""request":null"#), "got {text}");
assert!(text.contains(r#""actor":{"kind":"server"}"#), "got {text}");
}
}