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    /// Keys, as `dir#key`, that a file this change adds to a keyed
364    /// directory shares with another file of its tree (`paths.keyed`).
365    /// Computed with the policy in force, from format 29.
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    pub taken_keys: Vec<String>,
368}
369
370fn is_zero(n: &u32) -> bool {
371    *n == 0
372}
373
374impl Facts {
375    /// This change can break code it did not touch: what depends on a
376    /// signature, or something mentions a name it takes away.
377    pub fn reaches_others(&self) -> bool {
378        !self.signature_changes.is_empty() || !self.affected.is_empty() || self.affected_more > 0
379    }
380}
381
382#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384pub enum CheckKind {
385    Syntax,
386    Scope,
387    ClaimDiff,
388    Merge,
389    Build,
390    Tests,
391    TestIntegrity,
392    /// `verification.on_signature_change`.
393    Extended,
394    /// One of `verification.checks`, by the name in `details["name"]`.
395    Named,
396}
397
398#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400pub enum CheckResult {
401    Pass,
402    Fail,
403    Error,
404    Skipped,
405}
406
407/// Evidence produced by a (possibly nondeterministic) check. Recorded in the
408/// operation log so that decisions can be replayed without rerunning it.
409#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
410pub struct Attestation {
411    pub check: CheckKind,
412    pub result: CheckResult,
413    pub subject_tree: GitId,
414    pub details: BTreeMap<String, String>,
415    /// What names the environment the check ran in. For a command, a
416    /// digest of the sandbox, the command, its `PATH`, the policy's fixed
417    /// values, the names passed through (never their values) and what each
418    /// `verification.identify` command printed, whose first lines are in
419    /// `details` (`env.<n>.command`, `.output`, `.error`). Entries from
420    /// before identify (0.2.x) digest the sandbox and the command only.
421    /// `tree-sitter` for the syntax check. Recorded, never recomputed.
422    pub env: String,
423    pub log: Option<GitId>,
424    pub producer: String,
425}
426
427#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(rename_all = "snake_case")]
429pub enum Phase {
430    Pre,
431    Post,
432}
433
434#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(tag = "type", rename_all = "snake_case")]
436pub enum ObsKind {
437    Read {
438        path: String,
439    },
440    Write {
441        path: String,
442    },
443    /// The program name and a digest of the full command; the command text
444    /// itself is not logged because it may contain secrets.
445    Shell {
446        program: String,
447        digest: String,
448    },
449    Fetch {
450        host: String,
451    },
452    Mcp {
453        tool: String,
454    },
455    Other {
456        tool: String,
457    },
458    Heartbeat,
459    /// What the workspace held that no standing proposal of the session
460    /// carries, when the agent stopped or ended its session. `paths` lists
461    /// at most [`MAX_UNPROPOSED_PATHS`] of `total`; a `total` of 0 says the
462    /// work is gone or delivered.
463    Unproposed {
464        paths: Vec<String>,
465        total: u64,
466    },
467    /// `lonca mcp` ran `tool` and the harness's before-tool event for that
468    /// call never reached the daemon: the session's hooks are not running.
469    /// `tool` is the MCP tool's name, without the harness's prefix.
470    Unhooked {
471        tool: String,
472    },
473}
474
475/// The most paths one [`ObsKind::Unproposed`] names.
476pub const MAX_UNPROPOSED_PATHS: usize = 32;
477
478#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
479pub struct Observation {
480    pub phase: Phase,
481    pub kind: ObsKind,
482    /// The observation is outside the workspace or names untrusted content.
483    pub external: bool,
484}
485
486#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(tag = "type", rename_all = "snake_case")]
488pub enum NegotiationMsg {
489    /// "I need these symbols for this reason."
490    Claim {
491        symbols: Vec<String>,
492        reason: String,
493    },
494    /// Offer to split work: the sender keeps `mine`, the receiver gets `yours`.
495    ProposeSplit {
496        mine: Vec<String>,
497        yours: Vec<String>,
498    },
499    /// Give up the named lease set in favour of the receiver. With `to` the
500    /// set moves to that session in this entry, so nobody can take it in
501    /// between; without, it is freed.
502    Yield {
503        set: String,
504        #[serde(default, skip_serializing_if = "Option::is_none")]
505        to: Option<String>,
506    },
507    Counter {
508        note: String,
509    },
510    Accept,
511    Decline {
512        note: String,
513    },
514}
515
516#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
517pub struct HumanKey {
518    /// `ed25519` (file key, no user presence) or `p256-se` (Secure Enclave,
519    /// Touch ID or password on every signature).
520    pub alg: String,
521    pub public: String,
522}
523
524/// What a `daemon.recover` signed with a recovery key also does: replace
525/// that human's lost key, as `human.recover` does. The recovery key is
526/// used up by it.
527#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
528pub struct Recovery {
529    pub human: String,
530    pub key: HumanKey,
531}
532
533/// A new session taking over the workspace of an earlier one.
534#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
535pub struct Continuation {
536    pub session: String,
537    /// The human asked for a scope wider than the earlier session's.
538    pub widen: bool,
539}
540
541#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(tag = "op")]
543pub enum Op {
544    #[serde(rename = "repo.init")]
545    RepoInit {
546        repo: String,
547        daemon_key: String,
548        human: String,
549        human_key: HumanKey,
550        policy: GitId,
551        trunk: GitId,
552    },
553    #[serde(rename = "human.enroll")]
554    HumanEnroll { human: String, key: HumanKey },
555    /// Replaces the signer's own key. Signed with the current key.
556    #[serde(rename = "human.rotate")]
557    HumanRotate { human: String, key: HumanKey },
558    /// Withdraws a human's keys and everything they set in motion.
559    #[serde(rename = "human.revoke")]
560    HumanRevoke { human: String, reason: String },
561    /// Sets (or replaces) the signer's recovery key, kept off the machine.
562    /// Signed with the current key.
563    #[serde(rename = "human.recovery_key")]
564    HumanRecoveryKey { human: String, key: HumanKey },
565    /// Replaces a lost key. Signed with the human's recovery key, which is
566    /// used up by it.
567    #[serde(rename = "human.recover")]
568    HumanRecover { human: String, key: HumanKey },
569    /// Replaces the daemon key (Ed25519, hex). Signed by the old daemon key
570    /// and by a human; later entries are signed by the new key.
571    #[serde(rename = "daemon.rotate")]
572    DaemonRotate { key: String },
573    /// Re-anchors the log on a new daemon key when the old one is gone (or
574    /// belongs to a daemon that is being replaced). Signed by the new key
575    /// and by a human: with the human's key, or, with `recover`, with
576    /// their recovery key, when the Mac that held both keys is lost (log
577    /// format 27).
578    #[serde(rename = "daemon.recover")]
579    DaemonRecover {
580        key: String,
581        #[serde(default, skip_serializing_if = "Option::is_none")]
582        recover: Option<Recovery>,
583    },
584    #[serde(rename = "policy.update")]
585    PolicyUpdate { policy: GitId },
586    #[serde(rename = "session.start")]
587    SessionStart {
588        session: String,
589        agent: String,
590        model: Option<String>,
591        task: String,
592        scope: Scope,
593        expires_ms: i64,
594        /// The session whose workspace this one takes over.
595        #[serde(default, skip_serializing_if = "Option::is_none")]
596        continues: Option<Continuation>,
597        /// The harness the human runs the session under. Their word, and the
598        /// only one an observation level is read for (format 20).
599        #[serde(default, skip_serializing_if = "Option::is_none")]
600        harness: Option<String>,
601    },
602    #[serde(rename = "session.bind")]
603    SessionBind {
604        session: String,
605        checks: BindChecks,
606        level: ObservationLevel,
607    },
608    /// A human's word that the session runs in a sandbox Lonca built: what
609    /// gives it `level: isolated`. Never from the agent, and never from the
610    /// daemon, which can not see the sandbox: the launcher builds it and the
611    /// human who started the session signs for it.
612    #[serde(rename = "session.isolate")]
613    SessionIsolate {
614        session: String,
615        /// The kind of sandbox (`seatbelt`, `bwrap`).
616        sandbox: String,
617        /// SHA-256 of the sandbox's definition, lowercase hex.
618        profile: String,
619    },
620    #[serde(rename = "session.end")]
621    SessionEnd { session: String, reason: String },
622    #[serde(rename = "cap.attenuate")]
623    CapAttenuate {
624        parent: String,
625        child: String,
626        agent: String,
627        scope: Scope,
628        expires_ms: i64,
629        /// Lease sets the parent hands to the child; they come back when
630        /// the child ends.
631        #[serde(default, skip_serializing_if = "Vec::is_empty")]
632        leases: Vec<String>,
633    },
634    #[serde(rename = "cap.revoke")]
635    CapRevoke { session: String, reason: String },
636    #[serde(rename = "lease.acquire")]
637    LeaseAcquire {
638        set: String,
639        items: Vec<LeaseItem>,
640        intent: String,
641        ttl_ms: i64,
642    },
643    #[serde(rename = "lease.release")]
644    LeaseRelease { set: String },
645    #[serde(rename = "lease.renew")]
646    LeaseRenew { set: String, ttl_ms: i64 },
647    /// Holds nothing: when what conflicts with `items` is freed, the oldest
648    /// interest gets a short reservation. Until then, for a limited time,
649    /// no new lease can be taken that conflicts with `items`.
650    #[serde(rename = "lease.interest")]
651    LeaseInterest {
652        items: Vec<LeaseItem>,
653        intent: String,
654    },
655    #[serde(rename = "change.propose")]
656    ChangePropose {
657        change: ChangeId,
658        commit: GitId,
659        base: GitId,
660        claim: Claim,
661    },
662    #[serde(rename = "change.withdraw")]
663    ChangeWithdraw { change: ChangeId },
664    #[serde(rename = "change.integrate")]
665    ChangeIntegrate {
666        change: ChangeId,
667        trunk_before: GitId,
668        /// The merged trunk commit, or `None` when the merge conflicted.
669        merged: Option<GitId>,
670        conflicts: Vec<String>,
671    },
672    /// Changes at the head of the queue, integrated at once. One entry, so
673    /// that a crash can not leave trunk at a state nothing verified: the
674    /// evidence is of the last commit, and trunk moves straight to it. The
675    /// commits before it are in its history and were never trunk.
676    #[serde(rename = "change.integrate_batch")]
677    ChangeIntegrateBatch {
678        trunk_before: GitId,
679        /// Each change, in queue order, with the commit it merged to.
680        merged: Vec<(ChangeId, GitId)>,
681    },
682    /// `checks`: named checks of `verification.checks` the change owes from
683    /// here on, besides what it owed already; only on a proposal (log
684    /// format 25). Sorted, each name once.
685    #[serde(rename = "approve")]
686    Approve {
687        target_seq: u64,
688        note: String,
689        #[serde(default, skip_serializing_if = "Vec::is_empty")]
690        checks: Vec<String>,
691    },
692    /// A human declines an escalated operation; the reason is for the
693    /// requester (a worker fixes and proposes again) and is never
694    /// interpreted.
695    #[serde(rename = "reject")]
696    Reject { target_seq: u64, reason: String },
697    #[serde(rename = "observe")]
698    Observe {
699        session: String,
700        events: Vec<Observation>,
701    },
702    #[serde(rename = "taint")]
703    Taint { session: String, reason: String },
704    #[serde(rename = "import.external")]
705    ImportExternal {
706        remote: String,
707        commit: GitId,
708        trunk_before: GitId,
709    },
710    #[serde(rename = "import.unmediated")]
711    ImportUnmediated {
712        refname: String,
713        found: Option<GitId>,
714        expected: Option<GitId>,
715    },
716    #[serde(rename = "bridge.push")]
717    BridgePush {
718        remote: String,
719        oplog_seq: u64,
720        oplog_hash: String,
721    },
722    #[serde(rename = "freeze")]
723    Freeze { reason: String },
724    #[serde(rename = "unfreeze")]
725    Unfreeze { reason: String },
726    #[serde(rename = "negotiate")]
727    Negotiate {
728        thread: String,
729        to_session: String,
730        message: NegotiationMsg,
731    },
732    #[serde(rename = "redact")]
733    Redact { blob: GitId, reason: String },
734}
735
736impl Op {
737    pub fn kind(&self) -> OpKind {
738        match self {
739            Op::RepoInit { .. } => OpKind::RepoInit,
740            Op::HumanEnroll { .. } => OpKind::HumanEnroll,
741            Op::HumanRotate { .. } => OpKind::HumanRotate,
742            Op::HumanRevoke { .. } => OpKind::HumanRevoke,
743            Op::HumanRecoveryKey { .. } => OpKind::HumanRecoveryKey,
744            Op::HumanRecover { .. } => OpKind::HumanRecover,
745            Op::DaemonRotate { .. } => OpKind::DaemonRotate,
746            Op::DaemonRecover { .. } => OpKind::DaemonRecover,
747            Op::PolicyUpdate { .. } => OpKind::PolicyUpdate,
748            Op::SessionStart { .. } => OpKind::SessionStart,
749            Op::SessionBind { .. } => OpKind::SessionBind,
750            Op::SessionIsolate { .. } => OpKind::SessionIsolate,
751            Op::SessionEnd { .. } => OpKind::SessionEnd,
752            Op::CapAttenuate { .. } => OpKind::CapAttenuate,
753            Op::CapRevoke { .. } => OpKind::CapRevoke,
754            Op::LeaseAcquire { .. } => OpKind::LeaseAcquire,
755            Op::LeaseRelease { .. } => OpKind::LeaseRelease,
756            Op::LeaseRenew { .. } => OpKind::LeaseRenew,
757            Op::LeaseInterest { .. } => OpKind::LeaseInterest,
758            Op::ChangePropose { .. } => OpKind::ChangePropose,
759            Op::ChangeWithdraw { .. } => OpKind::ChangeWithdraw,
760            Op::ChangeIntegrate { .. } => OpKind::ChangeIntegrate,
761            Op::ChangeIntegrateBatch { .. } => OpKind::ChangeIntegrateBatch,
762            Op::Approve { .. } => OpKind::Approve,
763            Op::Reject { .. } => OpKind::Reject,
764            Op::Observe { .. } => OpKind::Observe,
765            Op::Taint { .. } => OpKind::Taint,
766            Op::ImportExternal { .. } => OpKind::ImportExternal,
767            Op::ImportUnmediated { .. } => OpKind::ImportUnmediated,
768            Op::BridgePush { .. } => OpKind::BridgePush,
769            Op::Freeze { .. } => OpKind::Freeze,
770            Op::Unfreeze { .. } => OpKind::Unfreeze,
771            Op::Negotiate { .. } => OpKind::Negotiate,
772            Op::Redact { .. } => OpKind::Redact,
773        }
774    }
775
776    /// Whether the human's recovery key signs this operation, not their
777    /// current key: `human.recover`, and `daemon.recover` with `recover`.
778    pub fn signed_by_recovery_key(&self) -> bool {
779        matches!(
780            self,
781            Op::HumanRecover { .. }
782                | Op::DaemonRecover {
783                    recover: Some(_),
784                    ..
785                }
786        )
787    }
788
789    /// The daemon key this operation installs, if it is a key switch.
790    pub fn daemon_key_switch(&self) -> Option<&str> {
791        match self {
792            Op::DaemonRotate { key } | Op::DaemonRecover { key, .. } => Some(key),
793            _ => None,
794        }
795    }
796}
797
798#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(rename_all = "snake_case")]
800pub enum Outcome {
801    Allow,
802    Deny,
803    Escalate,
804}
805
806#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
807pub struct Decision {
808    pub outcome: Outcome,
809    /// Machine-readable reason codes such as `lease_missing:src/a.rs#f`.
810    pub reasons: Vec<String>,
811}
812
813impl Decision {
814    pub fn allow() -> Self {
815        Self {
816            outcome: Outcome::Allow,
817            reasons: vec![],
818        }
819    }
820
821    pub fn deny(reason: impl Into<String>) -> Self {
822        Self {
823            outcome: Outcome::Deny,
824            reasons: vec![reason.into()],
825        }
826    }
827
828    pub fn escalate(reason: impl Into<String>) -> Self {
829        Self {
830            outcome: Outcome::Escalate,
831            reasons: vec![reason.into()],
832        }
833    }
834
835    pub fn is_allow(&self) -> bool {
836        self.outcome == Outcome::Allow
837    }
838}
839
840/// A human's signature over an operation. The signed message is the
841/// canonical JSON of [`HumanIntent`].
842#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
843pub struct HumanSignature {
844    pub human: String,
845    pub nonce: String,
846    pub issued_ms: i64,
847    pub sig: String,
848}
849
850#[derive(Serialize)]
851pub struct HumanIntent<'a> {
852    pub purpose: &'static str,
853    pub repo: &'a str,
854    pub op: &'a Op,
855    pub nonce: &'a str,
856    pub issued_ms: i64,
857}
858
859impl<'a> HumanIntent<'a> {
860    pub fn new(repo: &'a str, op: &'a Op, nonce: &'a str, issued_ms: i64) -> Self {
861        Self {
862            purpose: "lonca-human-intent-v1",
863            repo,
864            op,
865            nonce,
866            issued_ms,
867        }
868    }
869}
870
871/// What a human signs to open a human session (`human.login`). Its own
872/// purpose string keeps a login signature from ever passing as an
873/// operation signature, and the other way round.
874#[derive(Serialize)]
875pub struct LoginIntent<'a> {
876    pub purpose: &'static str,
877    /// The repository whose enrolment verifies the key; `None` lets the
878    /// daemon look in every repository.
879    pub repo: Option<&'a str>,
880    pub human: &'a str,
881    pub ttl_ms: i64,
882    pub nonce: &'a str,
883    pub issued_ms: i64,
884}
885
886impl<'a> LoginIntent<'a> {
887    pub fn new(
888        repo: Option<&'a str>,
889        human: &'a str,
890        ttl_ms: i64,
891        nonce: &'a str,
892        issued_ms: i64,
893    ) -> Self {
894        Self {
895            purpose: "lonca-human-login-v1",
896            repo,
897            human,
898            ttl_ms,
899            nonce,
900            issued_ms,
901        }
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    /// The names requests use are the names in entries.
910    #[test]
911    fn lease_kinds_are_named_as_they_are_written() {
912        for kind in LeaseKind::ALL {
913            let written = serde_json::to_value(kind).unwrap();
914            assert_eq!(written, kind.as_str());
915            assert_eq!(LeaseKind::from_name(kind.as_str()), Some(kind));
916        }
917        assert_eq!(LeaseKind::from_name("Shared"), None);
918        assert_eq!(LeaseKind::from_name(""), None);
919    }
920
921    #[test]
922    fn a_change_reaches_others_by_any_one_of_three() {
923        assert!(!Facts::default().reaches_others());
924        let signature = Facts {
925            signature_changes: vec!["a.rs#f".into()],
926            ..Facts::default()
927        };
928        let mentioned = Facts {
929            affected: vec!["a.rs#g".into()],
930            ..Facts::default()
931        };
932        let cut_off = Facts {
933            affected_more: 1,
934            ..Facts::default()
935        };
936        assert!(signature.reaches_others());
937        assert!(mentioned.reaches_others());
938        assert!(cut_off.reaches_others());
939    }
940
941    /// Left out when empty, so older entries keep their bytes; kept when not.
942    #[test]
943    fn what_was_cut_from_affected_is_written_out() {
944        let none = serde_json::to_string(&Facts::default()).unwrap();
945        assert!(!none.contains("affected"), "{none}");
946        let cut_off = Facts {
947            affected_more: 3,
948            ..Facts::default()
949        };
950        let text = serde_json::to_string(&cut_off).unwrap();
951        assert!(text.contains("\"affected_more\":3"), "{text}");
952    }
953
954    #[test]
955    fn unknown_op_kind_is_rejected() {
956        assert!(serde_json::from_str::<OpKind>("\"change.propose\"").is_ok());
957        assert!(serde_json::from_str::<OpKind>("\"admin.everything\"").is_err());
958    }
959
960    #[test]
961    fn op_kind_names_match_serde() {
962        for k in [
963            OpKind::RepoInit,
964            OpKind::ChangePropose,
965            OpKind::LeaseAcquire,
966            OpKind::Redact,
967            OpKind::SessionBind,
968            OpKind::SessionIsolate,
969            OpKind::HumanRotate,
970            OpKind::HumanRevoke,
971            OpKind::HumanRecoveryKey,
972            OpKind::HumanRecover,
973            OpKind::DaemonRotate,
974            OpKind::DaemonRecover,
975        ] {
976            assert_eq!(
977                serde_json::to_string(&k).unwrap(),
978                format!("\"{}\"", k.as_str())
979            );
980        }
981    }
982
983    #[test]
984    fn reserved_ops_are_not_grantable() {
985        for k in [
986            OpKind::PolicyUpdate,
987            OpKind::Approve,
988            OpKind::CapRevoke,
989            OpKind::Redact,
990            OpKind::HumanEnroll,
991            OpKind::HumanRotate,
992            OpKind::HumanRevoke,
993            OpKind::HumanRecoveryKey,
994            OpKind::HumanRecover,
995            OpKind::DaemonRotate,
996            OpKind::DaemonRecover,
997            OpKind::Freeze,
998            OpKind::ImportExternal,
999        ] {
1000            assert!(!k.agent_grantable(), "{k:?}");
1001        }
1002    }
1003}