Skip to main content

lonca_core/
ops.rs

1//! The operation vocabulary. Every state change in a Lonca repository is one
2//! of these, recorded (allowed or not) in the operation log.
3
4use std::collections::BTreeMap;
5use std::fmt;
6
7use serde::{Deserialize, Serialize};
8
9use crate::changeid::ChangeId;
10use crate::objects::GitId;
11
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "kind", rename_all = "snake_case")]
14pub enum Actor {
15    Human { name: String },
16    Agent { name: String, session: String },
17    Daemon,
18}
19
20impl Actor {
21    pub fn label(&self) -> String {
22        match self {
23            Actor::Human { name } => format!("human:{name}"),
24            Actor::Agent { name, session } => format!("agent:{name}/{session}"),
25            Actor::Daemon => "daemon".into(),
26        }
27    }
28
29    pub fn session(&self) -> Option<&str> {
30        match self {
31            Actor::Agent { session, .. } => Some(session),
32            _ => None,
33        }
34    }
35}
36
37impl fmt::Display for Actor {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.write_str(&self.label())
40    }
41}
42
43/// Operation types. Deserialization rejects anything not listed here, which
44/// makes every scope check deny-by-default.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46pub enum OpKind {
47    #[serde(rename = "repo.init")]
48    RepoInit,
49    #[serde(rename = "human.enroll")]
50    HumanEnroll,
51    #[serde(rename = "human.rotate")]
52    HumanRotate,
53    #[serde(rename = "human.revoke")]
54    HumanRevoke,
55    #[serde(rename = "human.recovery_key")]
56    HumanRecoveryKey,
57    #[serde(rename = "human.recover")]
58    HumanRecover,
59    #[serde(rename = "daemon.rotate")]
60    DaemonRotate,
61    #[serde(rename = "daemon.recover")]
62    DaemonRecover,
63    #[serde(rename = "policy.update")]
64    PolicyUpdate,
65    #[serde(rename = "session.start")]
66    SessionStart,
67    #[serde(rename = "session.bind")]
68    SessionBind,
69    #[serde(rename = "session.isolate")]
70    SessionIsolate,
71    #[serde(rename = "session.end")]
72    SessionEnd,
73    #[serde(rename = "cap.attenuate")]
74    CapAttenuate,
75    #[serde(rename = "cap.revoke")]
76    CapRevoke,
77    #[serde(rename = "lease.acquire")]
78    LeaseAcquire,
79    #[serde(rename = "lease.release")]
80    LeaseRelease,
81    #[serde(rename = "lease.renew")]
82    LeaseRenew,
83    #[serde(rename = "lease.interest")]
84    LeaseInterest,
85    #[serde(rename = "change.propose")]
86    ChangePropose,
87    #[serde(rename = "change.withdraw")]
88    ChangeWithdraw,
89    #[serde(rename = "change.integrate")]
90    ChangeIntegrate,
91    #[serde(rename = "change.integrate_batch")]
92    ChangeIntegrateBatch,
93    #[serde(rename = "approve")]
94    Approve,
95    #[serde(rename = "reject")]
96    Reject,
97    #[serde(rename = "observe")]
98    Observe,
99    #[serde(rename = "taint")]
100    Taint,
101    #[serde(rename = "import.external")]
102    ImportExternal,
103    #[serde(rename = "import.unmediated")]
104    ImportUnmediated,
105    #[serde(rename = "bridge.push")]
106    BridgePush,
107    #[serde(rename = "freeze")]
108    Freeze,
109    #[serde(rename = "unfreeze")]
110    Unfreeze,
111    #[serde(rename = "negotiate")]
112    Negotiate,
113    #[serde(rename = "redact")]
114    Redact,
115}
116
117impl OpKind {
118    pub fn as_str(self) -> &'static str {
119        match self {
120            OpKind::RepoInit => "repo.init",
121            OpKind::HumanEnroll => "human.enroll",
122            OpKind::HumanRotate => "human.rotate",
123            OpKind::HumanRevoke => "human.revoke",
124            OpKind::HumanRecoveryKey => "human.recovery_key",
125            OpKind::HumanRecover => "human.recover",
126            OpKind::DaemonRotate => "daemon.rotate",
127            OpKind::DaemonRecover => "daemon.recover",
128            OpKind::PolicyUpdate => "policy.update",
129            OpKind::SessionStart => "session.start",
130            OpKind::SessionBind => "session.bind",
131            OpKind::SessionIsolate => "session.isolate",
132            OpKind::SessionEnd => "session.end",
133            OpKind::CapAttenuate => "cap.attenuate",
134            OpKind::CapRevoke => "cap.revoke",
135            OpKind::LeaseAcquire => "lease.acquire",
136            OpKind::LeaseRelease => "lease.release",
137            OpKind::LeaseRenew => "lease.renew",
138            OpKind::LeaseInterest => "lease.interest",
139            OpKind::ChangePropose => "change.propose",
140            OpKind::ChangeWithdraw => "change.withdraw",
141            OpKind::ChangeIntegrate => "change.integrate",
142            OpKind::ChangeIntegrateBatch => "change.integrate_batch",
143            OpKind::Approve => "approve",
144            OpKind::Reject => "reject",
145            OpKind::Observe => "observe",
146            OpKind::Taint => "taint",
147            OpKind::ImportExternal => "import.external",
148            OpKind::ImportUnmediated => "import.unmediated",
149            OpKind::BridgePush => "bridge.push",
150            OpKind::Freeze => "freeze",
151            OpKind::Unfreeze => "unfreeze",
152            OpKind::Negotiate => "negotiate",
153            OpKind::Redact => "redact",
154        }
155    }
156
157    /// The only operation types a capability can ever grant to an agent.
158    /// Everything else (policy, keys, approvals, imports, revocation of
159    /// others, redaction, freezing) is reserved for humans or the daemon and
160    /// cannot be granted by any policy.
161    pub fn agent_grantable(self) -> bool {
162        matches!(
163            self,
164            OpKind::ChangePropose
165                | OpKind::ChangeWithdraw
166                | OpKind::LeaseAcquire
167                | OpKind::LeaseRelease
168                | OpKind::LeaseRenew
169                | OpKind::LeaseInterest
170                | OpKind::Negotiate
171                | OpKind::CapAttenuate
172        )
173    }
174}
175
176/// What a session may touch. Patterns use the language in [`crate::paths`].
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct Scope {
179    pub paths: Vec<String>,
180    /// `None`: no symbol restriction beyond `paths`.
181    pub symbols: Option<Vec<String>>,
182    pub ops: Vec<OpKind>,
183    pub max_files: Option<u32>,
184    pub max_lines: Option<u32>,
185    /// Absolute directories outside the workspace the agent may write to
186    /// (notes, logs, throwaway files). Writes there are allowed and are
187    /// not facts; nothing there can be proposed. Left out of the encoding
188    /// when empty, so entries without one keep their bytes (format 5).
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub scratch: Vec<String>,
191}
192
193#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum ObservationLevel {
196    /// Nothing watches the agent. Treated as tainted by default policy.
197    Unobserved,
198    /// Locked, blocking harness hooks (H1).
199    Hooks,
200    /// H1 plus a locked shell sandbox whose network goes through the daemon (H2).
201    HooksSandbox,
202    /// Container / OS sandbox owned by Lonca.
203    Isolated,
204}
205
206#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
207pub struct BindChecks {
208    pub harness: String,
209    pub harness_session: String,
210    /// Hooks come from root-owned managed settings with managed-only hooks.
211    pub managed_hooks: bool,
212    /// Shell sandbox enabled, locked, network routed to the Lonca proxy.
213    pub locked_sandbox: bool,
214    /// `sudo -n true` succeeded without a password.
215    pub sudo_without_password: bool,
216    /// Hash of the harness configuration that defines the hooks.
217    pub config_digest: String,
218    /// The hook program and its parent directories are not user-writable.
219    pub hook_binary_protected: bool,
220}
221
222#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum LeaseKind {
225    /// Exclusive: change the body of one symbol.
226    Edit,
227    /// Shared: add new members to a container (file or type).
228    Insert,
229    /// Exclusive: rename or delete a symbol.
230    Rename,
231    /// Shared: change the body of a symbol alongside others who hold it
232    /// `shared`; the merge joins their lines (log format 21, needs
233    /// `leases.shared`).
234    Shared,
235}
236
237impl LeaseKind {
238    pub const ALL: [LeaseKind; 4] = [Self::Edit, Self::Insert, Self::Rename, Self::Shared];
239
240    /// The name in entries and requests.
241    pub fn as_str(self) -> &'static str {
242        match self {
243            Self::Edit => "edit",
244            Self::Insert => "insert",
245            Self::Rename => "rename",
246            Self::Shared => "shared",
247        }
248    }
249
250    pub fn from_name(name: &str) -> Option<Self> {
251        Self::ALL.into_iter().find(|k| k.as_str() == name)
252    }
253}
254
255impl LeaseItem {
256    /// An item with no reserved names.
257    pub fn new(symbol: impl Into<String>, kind: LeaseKind) -> Self {
258        Self {
259            symbol: symbol.into(),
260            kind,
261            names: Vec::new(),
262        }
263    }
264
265    pub fn is_shared(&self) -> bool {
266        self.kind == LeaseKind::Shared
267    }
268}
269
270#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
271pub struct LeaseItem {
272    /// `path#Qualified.name`; for `insert`, the container (`path#Type` or `path#<file>`).
273    pub symbol: String,
274    pub kind: LeaseKind,
275    /// For `insert` only: the names of the members it adds, bare (no key).
276    /// Two inserts into one container whose names meet conflict, and an
277    /// addition under this item must be one of them. Empty reserves nothing
278    /// and allows any addition. Left out of the encoding when empty, so
279    /// entries without names keep their bytes (log format 22).
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub names: Vec<String>,
282}
283
284#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
285pub struct TestDelta {
286    pub added: u32,
287    pub modified: u32,
288    pub deleted: u32,
289    /// Tests that gained a skip/ignore marker.
290    pub skipped: u32,
291}
292
293/// The agent's structured declaration of what its change does. Compared
294/// field by field with [`Facts`]; any mismatch rejects the change.
295#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
296pub struct Claim {
297    /// Free text for humans. Never a decision input.
298    pub intent: String,
299    pub edits: Vec<String>,
300    pub adds: Vec<String>,
301    pub deletes: Vec<String>,
302    pub deleted_files: Vec<String>,
303    pub signature_changes: Vec<String>,
304    pub tests: TestDelta,
305    pub revert_of: Option<ChangeId>,
306}
307
308/// The member of a type that stands for its own text: `Type.<def>` is the
309/// header, fields, decorators and class-level statements, and for Rust every
310/// `impl` header of the type, without its methods.
311pub const DEF: &str = "<def>";
312
313/// A file whose symbols are sections: their names are its headings and keys,
314/// written by people, and a `(` in one is only that.
315pub fn names_are_headings(path: &str) -> bool {
316    let ext = path.rsplit_once('.').map(|(_, ext)| ext);
317    matches!(
318        ext,
319        Some("md" | "markdown" | "toml" | "json" | "yml" | "yaml")
320    )
321}
322
323/// The first symbol engine whose facts name `Type.<def>`. Before it the same
324/// text could only be the heading of a document section.
325pub const ENGINE_DEFS: u32 = 3;
326
327/// What a change actually does, computed by Lonca from the Git objects.
328#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
329pub struct Facts {
330    pub edits: Vec<String>,
331    pub adds: Vec<String>,
332    pub deletes: Vec<String>,
333    pub added_files: Vec<String>,
334    pub deleted_files: Vec<String>,
335    pub modified_files: Vec<String>,
336    pub signature_changes: Vec<String>,
337    /// Lines changed inside import blocks only (lease-free, merged as sets).
338    pub import_only_files: Vec<String>,
339    pub tests: TestDelta,
340    pub lines_added: u32,
341    pub lines_removed: u32,
342    /// Symbols that go on under another name: `[old, new]`. A renamed one is
343    /// also in `deletes` and `adds`, one with new parameters in `edits`.
344    #[serde(default, skip_serializing_if = "Vec::is_empty")]
345    pub renames: Vec<[String; 2]>,
346    /// Text files changed that no parser reads, whole-file edits in `edits`.
347    /// Under `leases.unparsed_files = "optimistic"` they need no lease.
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub unparsed_files: Vec<String>,
350    /// Files that a grammar read until this change and can not read after
351    /// it. From here on facts about them say nothing of their symbols, so a
352    /// person has to agree.
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub unread_files: Vec<String>,
355    /// Symbols of the base that mention by name what this change gives a new
356    /// signature, renames or deletes, and that it leaves as they are. By name
357    /// only: a guess that errs towards too many, never a call graph.
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub affected: Vec<String>,
360    /// How many more there were than `affected` holds.
361    #[serde(default, skip_serializing_if = "is_zero")]
362    pub affected_more: u32,
363}
364
365fn is_zero(n: &u32) -> bool {
366    *n == 0
367}
368
369impl Facts {
370    /// This change can break code it did not touch: what depends on a
371    /// signature, or something mentions a name it takes away.
372    pub fn reaches_others(&self) -> bool {
373        !self.signature_changes.is_empty() || !self.affected.is_empty() || self.affected_more > 0
374    }
375}
376
377#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
378#[serde(rename_all = "snake_case")]
379pub enum CheckKind {
380    Syntax,
381    Scope,
382    ClaimDiff,
383    Merge,
384    Build,
385    Tests,
386    TestIntegrity,
387    /// `verification.on_signature_change`.
388    Extended,
389    /// One of `verification.checks`, by the name in `details["name"]`.
390    Named,
391}
392
393#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395pub enum CheckResult {
396    Pass,
397    Fail,
398    Error,
399    Skipped,
400}
401
402/// Evidence produced by a (possibly nondeterministic) check. Recorded in the
403/// operation log so that decisions can be replayed without rerunning it.
404#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
405pub struct Attestation {
406    pub check: CheckKind,
407    pub result: CheckResult,
408    pub subject_tree: GitId,
409    pub details: BTreeMap<String, String>,
410    /// Digest of the environment: sandbox profile, commands, tool versions.
411    pub env: String,
412    pub log: Option<GitId>,
413    pub producer: String,
414}
415
416#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(rename_all = "snake_case")]
418pub enum Phase {
419    Pre,
420    Post,
421}
422
423#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(tag = "type", rename_all = "snake_case")]
425pub enum ObsKind {
426    Read {
427        path: String,
428    },
429    Write {
430        path: String,
431    },
432    /// The program name and a digest of the full command; the command text
433    /// itself is not logged because it may contain secrets.
434    Shell {
435        program: String,
436        digest: String,
437    },
438    Fetch {
439        host: String,
440    },
441    Mcp {
442        tool: String,
443    },
444    Other {
445        tool: String,
446    },
447    Heartbeat,
448    /// What the workspace held that no standing proposal of the session
449    /// carries, when the agent stopped or ended its session. `paths` lists
450    /// at most [`MAX_UNPROPOSED_PATHS`] of `total`; a `total` of 0 says the
451    /// work is gone or delivered.
452    Unproposed {
453        paths: Vec<String>,
454        total: u64,
455    },
456    /// `lonca mcp` ran `tool` and the harness's before-tool event for that
457    /// call never reached the daemon: the session's hooks are not running.
458    /// `tool` is the MCP tool's name, without the harness's prefix.
459    Unhooked {
460        tool: String,
461    },
462}
463
464/// The most paths one [`ObsKind::Unproposed`] names.
465pub const MAX_UNPROPOSED_PATHS: usize = 32;
466
467#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
468pub struct Observation {
469    pub phase: Phase,
470    pub kind: ObsKind,
471    /// The observation is outside the workspace or names untrusted content.
472    pub external: bool,
473}
474
475#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
476#[serde(tag = "type", rename_all = "snake_case")]
477pub enum NegotiationMsg {
478    /// "I need these symbols for this reason."
479    Claim {
480        symbols: Vec<String>,
481        reason: String,
482    },
483    /// Offer to split work: the sender keeps `mine`, the receiver gets `yours`.
484    ProposeSplit {
485        mine: Vec<String>,
486        yours: Vec<String>,
487    },
488    /// Give up the named lease set in favour of the receiver. With `to` the
489    /// set moves to that session in this entry, so nobody can take it in
490    /// between; without, it is freed.
491    Yield {
492        set: String,
493        #[serde(default, skip_serializing_if = "Option::is_none")]
494        to: Option<String>,
495    },
496    Counter {
497        note: String,
498    },
499    Accept,
500    Decline {
501        note: String,
502    },
503}
504
505#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
506pub struct HumanKey {
507    /// `ed25519` (file key, no user presence) or `p256-se` (Secure Enclave,
508    /// Touch ID or password on every signature).
509    pub alg: String,
510    pub public: String,
511}
512
513/// What a `daemon.recover` signed with a recovery key also does: replace
514/// that human's lost key, as `human.recover` does. The recovery key is
515/// used up by it.
516#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
517pub struct Recovery {
518    pub human: String,
519    pub key: HumanKey,
520}
521
522/// A new session taking over the workspace of an earlier one.
523#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
524pub struct Continuation {
525    pub session: String,
526    /// The human asked for a scope wider than the earlier session's.
527    pub widen: bool,
528}
529
530#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(tag = "op")]
532pub enum Op {
533    #[serde(rename = "repo.init")]
534    RepoInit {
535        repo: String,
536        daemon_key: String,
537        human: String,
538        human_key: HumanKey,
539        policy: GitId,
540        trunk: GitId,
541    },
542    #[serde(rename = "human.enroll")]
543    HumanEnroll { human: String, key: HumanKey },
544    /// Replaces the signer's own key. Signed with the current key.
545    #[serde(rename = "human.rotate")]
546    HumanRotate { human: String, key: HumanKey },
547    /// Withdraws a human's keys and everything they set in motion.
548    #[serde(rename = "human.revoke")]
549    HumanRevoke { human: String, reason: String },
550    /// Sets (or replaces) the signer's recovery key, kept off the machine.
551    /// Signed with the current key.
552    #[serde(rename = "human.recovery_key")]
553    HumanRecoveryKey { human: String, key: HumanKey },
554    /// Replaces a lost key. Signed with the human's recovery key, which is
555    /// used up by it.
556    #[serde(rename = "human.recover")]
557    HumanRecover { human: String, key: HumanKey },
558    /// Replaces the daemon key (Ed25519, hex). Signed by the old daemon key
559    /// and by a human; later entries are signed by the new key.
560    #[serde(rename = "daemon.rotate")]
561    DaemonRotate { key: String },
562    /// Re-anchors the log on a new daemon key when the old one is gone (or
563    /// belongs to a daemon that is being replaced). Signed by the new key
564    /// and by a human: with the human's key, or, with `recover`, with
565    /// their recovery key, when the Mac that held both keys is lost (log
566    /// format 27).
567    #[serde(rename = "daemon.recover")]
568    DaemonRecover {
569        key: String,
570        #[serde(default, skip_serializing_if = "Option::is_none")]
571        recover: Option<Recovery>,
572    },
573    #[serde(rename = "policy.update")]
574    PolicyUpdate { policy: GitId },
575    #[serde(rename = "session.start")]
576    SessionStart {
577        session: String,
578        agent: String,
579        model: Option<String>,
580        task: String,
581        scope: Scope,
582        expires_ms: i64,
583        /// The session whose workspace this one takes over.
584        #[serde(default, skip_serializing_if = "Option::is_none")]
585        continues: Option<Continuation>,
586        /// The harness the human runs the session under. Their word, and the
587        /// only one an observation level is read for (format 20).
588        #[serde(default, skip_serializing_if = "Option::is_none")]
589        harness: Option<String>,
590    },
591    #[serde(rename = "session.bind")]
592    SessionBind {
593        session: String,
594        checks: BindChecks,
595        level: ObservationLevel,
596    },
597    /// A human's word that the session runs in a sandbox Lonca built: what
598    /// gives it `level: isolated`. Never from the agent, and never from the
599    /// daemon, which can not see the sandbox: the launcher builds it and the
600    /// human who started the session signs for it.
601    #[serde(rename = "session.isolate")]
602    SessionIsolate {
603        session: String,
604        /// The kind of sandbox (`seatbelt`, `bwrap`).
605        sandbox: String,
606        /// SHA-256 of the sandbox's definition, lowercase hex.
607        profile: String,
608    },
609    #[serde(rename = "session.end")]
610    SessionEnd { session: String, reason: String },
611    #[serde(rename = "cap.attenuate")]
612    CapAttenuate {
613        parent: String,
614        child: String,
615        agent: String,
616        scope: Scope,
617        expires_ms: i64,
618        /// Lease sets the parent hands to the child; they come back when
619        /// the child ends.
620        #[serde(default, skip_serializing_if = "Vec::is_empty")]
621        leases: Vec<String>,
622    },
623    #[serde(rename = "cap.revoke")]
624    CapRevoke { session: String, reason: String },
625    #[serde(rename = "lease.acquire")]
626    LeaseAcquire {
627        set: String,
628        items: Vec<LeaseItem>,
629        intent: String,
630        ttl_ms: i64,
631    },
632    #[serde(rename = "lease.release")]
633    LeaseRelease { set: String },
634    #[serde(rename = "lease.renew")]
635    LeaseRenew { set: String, ttl_ms: i64 },
636    /// Holds nothing: when what conflicts with `items` is freed, the oldest
637    /// interest gets a short reservation. Until then, for a limited time,
638    /// no new lease can be taken that conflicts with `items`.
639    #[serde(rename = "lease.interest")]
640    LeaseInterest {
641        items: Vec<LeaseItem>,
642        intent: String,
643    },
644    #[serde(rename = "change.propose")]
645    ChangePropose {
646        change: ChangeId,
647        commit: GitId,
648        base: GitId,
649        claim: Claim,
650    },
651    #[serde(rename = "change.withdraw")]
652    ChangeWithdraw { change: ChangeId },
653    #[serde(rename = "change.integrate")]
654    ChangeIntegrate {
655        change: ChangeId,
656        trunk_before: GitId,
657        /// The merged trunk commit, or `None` when the merge conflicted.
658        merged: Option<GitId>,
659        conflicts: Vec<String>,
660    },
661    /// Changes at the head of the queue, integrated at once. One entry, so
662    /// that a crash can not leave trunk at a state nothing verified: the
663    /// evidence is of the last commit, and trunk moves straight to it. The
664    /// commits before it are in its history and were never trunk.
665    #[serde(rename = "change.integrate_batch")]
666    ChangeIntegrateBatch {
667        trunk_before: GitId,
668        /// Each change, in queue order, with the commit it merged to.
669        merged: Vec<(ChangeId, GitId)>,
670    },
671    /// `checks`: named checks of `verification.checks` the change owes from
672    /// here on, besides what it owed already; only on a proposal (log
673    /// format 25). Sorted, each name once.
674    #[serde(rename = "approve")]
675    Approve {
676        target_seq: u64,
677        note: String,
678        #[serde(default, skip_serializing_if = "Vec::is_empty")]
679        checks: Vec<String>,
680    },
681    /// A human declines an escalated operation; the reason is for the
682    /// requester (a worker fixes and proposes again) and is never
683    /// interpreted.
684    #[serde(rename = "reject")]
685    Reject { target_seq: u64, reason: String },
686    #[serde(rename = "observe")]
687    Observe {
688        session: String,
689        events: Vec<Observation>,
690    },
691    #[serde(rename = "taint")]
692    Taint { session: String, reason: String },
693    #[serde(rename = "import.external")]
694    ImportExternal {
695        remote: String,
696        commit: GitId,
697        trunk_before: GitId,
698    },
699    #[serde(rename = "import.unmediated")]
700    ImportUnmediated {
701        refname: String,
702        found: Option<GitId>,
703        expected: Option<GitId>,
704    },
705    #[serde(rename = "bridge.push")]
706    BridgePush {
707        remote: String,
708        oplog_seq: u64,
709        oplog_hash: String,
710    },
711    #[serde(rename = "freeze")]
712    Freeze { reason: String },
713    #[serde(rename = "unfreeze")]
714    Unfreeze { reason: String },
715    #[serde(rename = "negotiate")]
716    Negotiate {
717        thread: String,
718        to_session: String,
719        message: NegotiationMsg,
720    },
721    #[serde(rename = "redact")]
722    Redact { blob: GitId, reason: String },
723}
724
725impl Op {
726    pub fn kind(&self) -> OpKind {
727        match self {
728            Op::RepoInit { .. } => OpKind::RepoInit,
729            Op::HumanEnroll { .. } => OpKind::HumanEnroll,
730            Op::HumanRotate { .. } => OpKind::HumanRotate,
731            Op::HumanRevoke { .. } => OpKind::HumanRevoke,
732            Op::HumanRecoveryKey { .. } => OpKind::HumanRecoveryKey,
733            Op::HumanRecover { .. } => OpKind::HumanRecover,
734            Op::DaemonRotate { .. } => OpKind::DaemonRotate,
735            Op::DaemonRecover { .. } => OpKind::DaemonRecover,
736            Op::PolicyUpdate { .. } => OpKind::PolicyUpdate,
737            Op::SessionStart { .. } => OpKind::SessionStart,
738            Op::SessionBind { .. } => OpKind::SessionBind,
739            Op::SessionIsolate { .. } => OpKind::SessionIsolate,
740            Op::SessionEnd { .. } => OpKind::SessionEnd,
741            Op::CapAttenuate { .. } => OpKind::CapAttenuate,
742            Op::CapRevoke { .. } => OpKind::CapRevoke,
743            Op::LeaseAcquire { .. } => OpKind::LeaseAcquire,
744            Op::LeaseRelease { .. } => OpKind::LeaseRelease,
745            Op::LeaseRenew { .. } => OpKind::LeaseRenew,
746            Op::LeaseInterest { .. } => OpKind::LeaseInterest,
747            Op::ChangePropose { .. } => OpKind::ChangePropose,
748            Op::ChangeWithdraw { .. } => OpKind::ChangeWithdraw,
749            Op::ChangeIntegrate { .. } => OpKind::ChangeIntegrate,
750            Op::ChangeIntegrateBatch { .. } => OpKind::ChangeIntegrateBatch,
751            Op::Approve { .. } => OpKind::Approve,
752            Op::Reject { .. } => OpKind::Reject,
753            Op::Observe { .. } => OpKind::Observe,
754            Op::Taint { .. } => OpKind::Taint,
755            Op::ImportExternal { .. } => OpKind::ImportExternal,
756            Op::ImportUnmediated { .. } => OpKind::ImportUnmediated,
757            Op::BridgePush { .. } => OpKind::BridgePush,
758            Op::Freeze { .. } => OpKind::Freeze,
759            Op::Unfreeze { .. } => OpKind::Unfreeze,
760            Op::Negotiate { .. } => OpKind::Negotiate,
761            Op::Redact { .. } => OpKind::Redact,
762        }
763    }
764
765    /// Whether the human's recovery key signs this operation, not their
766    /// current key: `human.recover`, and `daemon.recover` with `recover`.
767    pub fn signed_by_recovery_key(&self) -> bool {
768        matches!(
769            self,
770            Op::HumanRecover { .. }
771                | Op::DaemonRecover {
772                    recover: Some(_),
773                    ..
774                }
775        )
776    }
777
778    /// The daemon key this operation installs, if it is a key switch.
779    pub fn daemon_key_switch(&self) -> Option<&str> {
780        match self {
781            Op::DaemonRotate { key } | Op::DaemonRecover { key, .. } => Some(key),
782            _ => None,
783        }
784    }
785}
786
787#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
788#[serde(rename_all = "snake_case")]
789pub enum Outcome {
790    Allow,
791    Deny,
792    Escalate,
793}
794
795#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
796pub struct Decision {
797    pub outcome: Outcome,
798    /// Machine-readable reason codes such as `lease_missing:src/a.rs#f`.
799    pub reasons: Vec<String>,
800}
801
802impl Decision {
803    pub fn allow() -> Self {
804        Self {
805            outcome: Outcome::Allow,
806            reasons: vec![],
807        }
808    }
809
810    pub fn deny(reason: impl Into<String>) -> Self {
811        Self {
812            outcome: Outcome::Deny,
813            reasons: vec![reason.into()],
814        }
815    }
816
817    pub fn escalate(reason: impl Into<String>) -> Self {
818        Self {
819            outcome: Outcome::Escalate,
820            reasons: vec![reason.into()],
821        }
822    }
823
824    pub fn is_allow(&self) -> bool {
825        self.outcome == Outcome::Allow
826    }
827}
828
829/// A human's signature over an operation. The signed message is the
830/// canonical JSON of [`HumanIntent`].
831#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
832pub struct HumanSignature {
833    pub human: String,
834    pub nonce: String,
835    pub issued_ms: i64,
836    pub sig: String,
837}
838
839#[derive(Serialize)]
840pub struct HumanIntent<'a> {
841    pub purpose: &'static str,
842    pub repo: &'a str,
843    pub op: &'a Op,
844    pub nonce: &'a str,
845    pub issued_ms: i64,
846}
847
848impl<'a> HumanIntent<'a> {
849    pub fn new(repo: &'a str, op: &'a Op, nonce: &'a str, issued_ms: i64) -> Self {
850        Self {
851            purpose: "lonca-human-intent-v1",
852            repo,
853            op,
854            nonce,
855            issued_ms,
856        }
857    }
858}
859
860/// What a human signs to open a human session (`human.login`). Its own
861/// purpose string keeps a login signature from ever passing as an
862/// operation signature, and the other way round.
863#[derive(Serialize)]
864pub struct LoginIntent<'a> {
865    pub purpose: &'static str,
866    /// The repository whose enrolment verifies the key; `None` lets the
867    /// daemon look in every repository.
868    pub repo: Option<&'a str>,
869    pub human: &'a str,
870    pub ttl_ms: i64,
871    pub nonce: &'a str,
872    pub issued_ms: i64,
873}
874
875impl<'a> LoginIntent<'a> {
876    pub fn new(
877        repo: Option<&'a str>,
878        human: &'a str,
879        ttl_ms: i64,
880        nonce: &'a str,
881        issued_ms: i64,
882    ) -> Self {
883        Self {
884            purpose: "lonca-human-login-v1",
885            repo,
886            human,
887            ttl_ms,
888            nonce,
889            issued_ms,
890        }
891    }
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897
898    /// The names requests use are the names in entries.
899    #[test]
900    fn lease_kinds_are_named_as_they_are_written() {
901        for kind in LeaseKind::ALL {
902            let written = serde_json::to_value(kind).unwrap();
903            assert_eq!(written, kind.as_str());
904            assert_eq!(LeaseKind::from_name(kind.as_str()), Some(kind));
905        }
906        assert_eq!(LeaseKind::from_name("Shared"), None);
907        assert_eq!(LeaseKind::from_name(""), None);
908    }
909
910    #[test]
911    fn a_change_reaches_others_by_any_one_of_three() {
912        assert!(!Facts::default().reaches_others());
913        let signature = Facts {
914            signature_changes: vec!["a.rs#f".into()],
915            ..Facts::default()
916        };
917        let mentioned = Facts {
918            affected: vec!["a.rs#g".into()],
919            ..Facts::default()
920        };
921        let cut_off = Facts {
922            affected_more: 1,
923            ..Facts::default()
924        };
925        assert!(signature.reaches_others());
926        assert!(mentioned.reaches_others());
927        assert!(cut_off.reaches_others());
928    }
929
930    /// Left out when empty, so older entries keep their bytes; kept when not.
931    #[test]
932    fn what_was_cut_from_affected_is_written_out() {
933        let none = serde_json::to_string(&Facts::default()).unwrap();
934        assert!(!none.contains("affected"), "{none}");
935        let cut_off = Facts {
936            affected_more: 3,
937            ..Facts::default()
938        };
939        let text = serde_json::to_string(&cut_off).unwrap();
940        assert!(text.contains("\"affected_more\":3"), "{text}");
941    }
942
943    #[test]
944    fn unknown_op_kind_is_rejected() {
945        assert!(serde_json::from_str::<OpKind>("\"change.propose\"").is_ok());
946        assert!(serde_json::from_str::<OpKind>("\"admin.everything\"").is_err());
947    }
948
949    #[test]
950    fn op_kind_names_match_serde() {
951        for k in [
952            OpKind::RepoInit,
953            OpKind::ChangePropose,
954            OpKind::LeaseAcquire,
955            OpKind::Redact,
956            OpKind::SessionBind,
957            OpKind::SessionIsolate,
958            OpKind::HumanRotate,
959            OpKind::HumanRevoke,
960            OpKind::HumanRecoveryKey,
961            OpKind::HumanRecover,
962            OpKind::DaemonRotate,
963            OpKind::DaemonRecover,
964        ] {
965            assert_eq!(
966                serde_json::to_string(&k).unwrap(),
967                format!("\"{}\"", k.as_str())
968            );
969        }
970    }
971
972    #[test]
973    fn reserved_ops_are_not_grantable() {
974        for k in [
975            OpKind::PolicyUpdate,
976            OpKind::Approve,
977            OpKind::CapRevoke,
978            OpKind::Redact,
979            OpKind::HumanEnroll,
980            OpKind::HumanRotate,
981            OpKind::HumanRevoke,
982            OpKind::HumanRecoveryKey,
983            OpKind::HumanRecover,
984            OpKind::DaemonRotate,
985            OpKind::DaemonRecover,
986            OpKind::Freeze,
987            OpKind::ImportExternal,
988        ] {
989            assert!(!k.agent_grantable(), "{k:?}");
990        }
991    }
992}