Skip to main content

comindly_api/
ledger.rs

1use std::collections::BTreeMap;
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::model::{Fingerprint, Symbol};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
9#[serde(rename_all = "snake_case")]
10pub enum Kind {
11    Intent,
12    Decision,
13    Attempt,
14    Constraint,
15    Gotcha,
16    Question,
17    /// A relation to another record (supersede, retract, confirm, dispute).
18    Relation,
19}
20
21impl Kind {
22    pub fn parse(s: &str) -> Option<Kind> {
23        Some(match s {
24            "intent" => Kind::Intent,
25            "decision" => Kind::Decision,
26            "attempt" => Kind::Attempt,
27            "constraint" => Kind::Constraint,
28            "gotcha" => Kind::Gotcha,
29            "question" => Kind::Question,
30            _ => return None,
31        })
32    }
33
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Kind::Intent => "intent",
37            Kind::Decision => "decision",
38            Kind::Attempt => "attempt",
39            Kind::Constraint => "constraint",
40            Kind::Gotcha => "gotcha",
41            Kind::Question => "question",
42            Kind::Relation => "relation",
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum RelationKind {
50    Supersedes,
51    Retracts,
52    Confirms,
53    Disputes,
54}
55
56impl RelationKind {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            RelationKind::Supersedes => "supersede",
60            RelationKind::Retracts => "retract",
61            RelationKind::Confirms => "confirm",
62            RelationKind::Disputes => "dispute",
63        }
64    }
65
66    pub fn parse(s: &str) -> Option<RelationKind> {
67        Some(match s {
68            "confirm" | "confirms" => RelationKind::Confirms,
69            "retract" | "retracts" => RelationKind::Retracts,
70            "supersede" | "supersedes" => RelationKind::Supersedes,
71            "dispute" | "disputes" => RelationKind::Disputes,
72            _ => return None,
73        })
74    }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Relation {
79    pub kind: RelationKind,
80    pub target: String,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum AuthorKind {
86    Human,
87    Agent,
88    /// Legacy (removed git harvest and distiller); kept so older records still read.
89    Harvested,
90    Distilled,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct Provenance {
95    pub author: AuthorKind,
96    /// Pseudonymous actor id (no names or emails).
97    pub actor: String,
98    pub source: String,
99    #[serde(default)]
100    pub agent: Option<String>,
101    #[serde(default)]
102    pub session: Option<String>,
103    /// The person's name, or the person an agent works for; absent with `record_names = false`.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub name: Option<String>,
106    /// The writer's teams when it was written; empty for older records and outside teams.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub teams: Vec<String>,
109}
110
111/// Who writes records: a pseudonymous actor id and, when names are kept, a name.
112#[derive(Debug, Clone, Default)]
113pub struct Identity {
114    pub actor: String,
115    pub name: Option<String>,
116}
117
118impl Provenance {
119    pub fn human(source: &str, who: &Identity) -> Provenance {
120        Provenance {
121            author: AuthorKind::Human,
122            actor: who.actor.clone(),
123            source: source.into(),
124            agent: None,
125            session: None,
126            name: who.name.clone(),
127            teams: Vec::new(),
128        }
129    }
130
131    pub fn agent(source: &str, agent: Option<String>, who: &Identity) -> Provenance {
132        Provenance {
133            author: AuthorKind::Agent,
134            agent,
135            ..Provenance::human(source, who)
136        }
137    }
138
139    /// Who, for people to read: "Ada Lovelace", "claude-code for Ada Lovelace", "an agent".
140    pub fn who(&self) -> String {
141        // Records written through a gateway before beta.9 carry the MCP library's name, not the agent's.
142        let agent = self.agent.as_deref().filter(|a| *a != "rmcp");
143        match (self.author, self.name.as_deref(), agent) {
144            (AuthorKind::Agent, Some(n), Some(a)) => format!("{a} for {n}"),
145            (AuthorKind::Agent, Some(n), None) => format!("an agent for {n}"),
146            (AuthorKind::Agent, None, Some(a)) => a.to_string(),
147            (AuthorKind::Agent, None, None) => "an agent".into(),
148            (AuthorKind::Human, Some(n), _) => n.to_string(),
149            (AuthorKind::Human, None, _) => "a person".into(),
150            (AuthorKind::Harvested, ..) => "git history".into(),
151            (AuthorKind::Distilled, ..) => "the distiller".into(),
152        }
153    }
154}
155
156/// Where a record points: a symbol as it was when the record was written.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct Anchor {
159    pub symbol: String,
160    pub repo: String,
161    pub path: String,
162    pub name: String,
163    pub line: u32,
164    #[serde(default)]
165    pub fingerprint: Option<Fingerprint>,
166    /// The indexed commit of the anchor's repository when the record was written.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub commit: Option<String>,
169}
170
171impl Anchor {
172    pub fn of(s: &Symbol) -> Anchor {
173        Anchor {
174            symbol: s.id.render(),
175            repo: s.repo.0.clone(),
176            path: s.file_path.clone(),
177            name: s.name.clone(),
178            line: s.range.start.line,
179            fingerprint: s.fingerprint,
180            commit: None,
181        }
182    }
183
184    /// With the commit `commits` (by repository) records for the anchor's repository.
185    pub fn at(mut self, commits: &std::collections::HashMap<String, String>) -> Anchor {
186        self.commit = commits.get(&self.repo).cloned();
187        self
188    }
189}
190
191/// Code that shaped a record, as it was at `commit`.
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub struct Snippet {
194    pub path: String,
195    pub code: String,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub commit: Option<String>,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct Record {
202    pub id: String,
203    pub schema: u32,
204    pub kind: Kind,
205    pub title: String,
206    #[serde(default)]
207    pub body: String,
208    /// When a future reader needs this ("relevant when adding caching to auth paths").
209    #[serde(default)]
210    pub trigger: Option<String>,
211    /// Kind-specific fields (`alternatives`, `outcome`, `rule`, `severity`, …).
212    #[serde(default)]
213    pub fields: BTreeMap<String, serde_json::Value>,
214    pub anchors: Vec<Anchor>,
215    /// `commit:<sha>`, `mr:!412`, `test:<path>::<name>`, `url:…`, or free text.
216    #[serde(default)]
217    pub evidence: Vec<String>,
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub snippets: Vec<Snippet>,
220    #[serde(default)]
221    pub relation: Option<Relation>,
222    pub provenance: Provenance,
223    /// RFC 3339, UTC.
224    pub created: String,
225    /// Optional SSH signature over the rest of the record .
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub signature: Option<Signature>,
228}
229
230impl Record {
231    pub fn new(kind: Kind, title: &str, anchors: Vec<Anchor>, provenance: Provenance) -> Record {
232        Record {
233            id: uuid::Uuid::now_v7().to_string(),
234            schema: SCHEMA,
235            kind,
236            title: title.trim().to_string(),
237            body: String::new(),
238            trigger: None,
239            fields: BTreeMap::new(),
240            anchors,
241            evidence: Vec::new(),
242            snippets: Vec::new(),
243            relation: None,
244            provenance,
245            created: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
246            signature: None,
247        }
248    }
249
250    /// The relation record for a review `action` (see [`review_action`]) on `target`.
251    pub fn review(
252        target: &Record,
253        action: &str,
254        reason: &str,
255        provenance: Provenance,
256    ) -> Result<Record> {
257        let (kind, retire) = review_action(action).with_context(|| {
258            format!("unknown action `{action}`: confirm, archive, invalidate, supersede, dispute or retract")
259        })?;
260        anyhow::ensure!(
261            !reason.trim().is_empty(),
262            "say why: every change to the ledger carries a reason other agents can read"
263        );
264        let mut r = Record::relation(kind, target, reason, provenance);
265        if let Some(why) = retire {
266            r.fields
267                .insert("retire".into(), serde_json::Value::String(why.into()));
268        }
269        Ok(r)
270    }
271
272    /// Mark a review an agent makes on its person's explicit say.
273    pub fn from_person(mut self) -> Record {
274        self.fields
275            .insert("from_person".into(), serde_json::Value::Bool(true));
276        self
277    }
278
279    /// A confirmation vouches for the code as indexed now: it takes the target's anchors from `now`.
280    pub fn vouching(mut self, target: &Record, now: impl Fn(&str) -> Option<Anchor>) -> Record {
281        if self.relation.as_ref().map(|rel| rel.kind) == Some(RelationKind::Confirms) {
282            self.anchors = target
283                .anchors
284                .iter()
285                .filter_map(|a| now(&a.symbol))
286                .collect();
287        }
288        self
289    }
290
291    /// A relation record (`confirm`, `retract`, …) about `target`.
292    pub fn relation(
293        kind: RelationKind,
294        target: &Record,
295        reason: &str,
296        provenance: Provenance,
297    ) -> Record {
298        let title = if reason.trim().is_empty() {
299            format!("{kind:?}: {}", target.title)
300        } else {
301            reason.trim().to_string()
302        };
303        let mut r = Record::new(Kind::Relation, &title, Vec::new(), provenance);
304        r.relation = Some(Relation {
305            kind,
306            target: target.id.clone(),
307        });
308        r
309    }
310
311    /// All free text of a record, for content checks.
312    fn text(&self) -> String {
313        let fields: Vec<String> = self.fields.values().map(|v| v.to_string()).collect();
314        let code: Vec<&str> = self.snippets.iter().map(|s| s.code.as_str()).collect();
315        format!(
316            "{} {} {} {} {} {}",
317            self.title,
318            self.body,
319            self.trigger.as_deref().unwrap_or(""),
320            self.evidence.join(" "),
321            fields.join(" "),
322            code.join(" ")
323        )
324    }
325
326    /// A record that slipped past write-time checks (planted straight into the store).
327    pub fn unsafe_content(&self) -> bool {
328        let t = self.text();
329        suspicious(&t).is_some()
330            || (self.provenance.author != AuthorKind::Human && injection(&t).is_some())
331    }
332
333    /// Reject records that would mislead or carry secrets; caps keep them reviewable.
334    pub fn validate(&self) -> Result<()> {
335        if self.title.is_empty() || self.title.chars().count() > 160 {
336            bail!("title must be 1–160 characters");
337        }
338        if self.body.chars().count() > 4000 {
339            bail!("body is limited to 4000 characters");
340        }
341        if self.kind != Kind::Relation && self.anchors.is_empty() {
342            bail!("a record needs at least one anchor (a symbol it is about)");
343        }
344        if self.fields.contains_key("check") && self.kind != Kind::Constraint {
345            bail!("only constraints carry a check");
346        }
347        if self.snippets.len() > MAX_SNIPPETS {
348            bail!("at most {MAX_SNIPPETS} snippets");
349        }
350        if self.snippets.iter().any(|s| {
351            s.path.trim().is_empty()
352                || s.code.trim().is_empty()
353                || s.code.lines().count() > MAX_SNIPPET_LINES
354                || s.code.chars().count() > 4000
355        }) {
356            bail!("a snippet needs a path and 1–{MAX_SNIPPET_LINES} lines of code (4000 characters at most)");
357        }
358        if self.kind == Kind::Attempt && self.evidence.is_empty() {
359            bail!("an attempt needs evidence (a revert commit, failing test, error, MR, …)");
360        }
361        let text = self.text();
362        if let Some(why) = suspicious(&text) {
363            bail!("refusing to store record: {why}");
364        }
365        // Ledger text is injected into agents' context: machine-written records must not carry
366        // instructions aimed at an agent or shell payloads.
367        if self.provenance.author != AuthorKind::Human {
368            if let Some(why) = injection(&text) {
369                bail!("refusing to store record: {why}");
370            }
371        }
372        Ok(())
373    }
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
377#[serde(rename_all = "snake_case")]
378pub enum Status {
379    Active,
380    /// The anchored symbol moved or was renamed; its body is unchanged.
381    Moved,
382    /// The anchored symbol's body changed since the record was written: re-verify.
383    Stale,
384    /// The anchored symbol no longer exists.
385    Orphaned,
386    Superseded,
387    Retracted,
388}
389
390/// T4 constraint enforced by a passing check, T3 human or human-confirmed, T2 agent with evidence,
391/// T1 agent assertion, T0 untrusted.
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
393pub enum Trust {
394    T0,
395    T1,
396    T2,
397    T3,
398    T4,
399}
400
401/// A record with its derived state against the current index.
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct Entry {
404    pub record: Record,
405    pub status: Status,
406    pub trust: Trust,
407    /// Current symbol ids the record's anchors map to (after moves/renames).
408    pub current: Vec<String>,
409    /// Ids of records that supersede, retract, confirm or dispute this one.
410    pub related: Vec<(RelationKind, String)>,
411    /// Signature check: verified, failed (record treated as T0), or unsigned/unchecked.
412    pub verified: Option<bool>,
413    /// Who wrote it, for people to read.
414    pub by: String,
415    /// Reviews of this record, oldest first.
416    pub history: Vec<Event>,
417    /// Why it was retracted: `outdated` (archived) or `wrong` (invalidated).
418    pub retired: Option<String>,
419    /// The writer's teams, when the note is about another team's code and none of its members confirmed it.
420    #[serde(default, skip_serializing_if = "Vec::is_empty")]
421    pub outside: Vec<String>,
422    /// People other than the writer who confirmed it, themselves or through their agents.
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub confirmers: Vec<String>,
425    /// Where the code is now, when a person's confirmation re-anchored the record.
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub vouched: Vec<Anchor>,
428    /// An older standing record this one reads like, until a person keeps or retires it.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub alike: Option<Alike>,
431}
432
433impl Entry {
434    /// The anchors to show: where a confirmation last saw the code, else where it was written.
435    pub fn anchors(&self) -> &[Anchor] {
436        if self.vouched.is_empty() {
437            &self.record.anchors
438        } else {
439            &self.vouched
440        }
441    }
442}
443
444/// The older record a record may repeat, and how alike they read (0 to 1).
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct Alike {
447    pub id: String,
448    pub title: String,
449    pub score: f32,
450}
451
452pub const SCHEMA: u32 = 1;
453
454/// A review action: `confirm`, `supersede`, `dispute`, `retract`, `archive` (no longer applies)
455/// or `invalidate` (was wrong).
456pub fn review_action(action: &str) -> Option<(RelationKind, Option<&'static str>)> {
457    match action {
458        "archive" => Some((RelationKind::Retracts, Some("outdated"))),
459        "invalidate" => Some((RelationKind::Retracts, Some("wrong"))),
460        other => RelationKind::parse(other).map(|k| (k, None)),
461    }
462}
463
464pub const MAX_SNIPPETS: usize = 3;
465
466pub const MAX_SNIPPET_LINES: usize = 60;
467
468/// Instruction or payload text that would steer an agent reading the record.
469pub fn injection(text: &str) -> Option<&'static str> {
470    let t = text.to_lowercase();
471    const STEERING: &[&str] = &[
472        "ignore previous",
473        "ignore all previous",
474        "ignore the above",
475        "disregard previous",
476        "disregard the above",
477        "system prompt",
478        "you are now",
479        "new instructions",
480        "<system",
481        "</system",
482        "<instructions",
483        "assistant:",
484        "do not tell the user",
485        "without telling the user",
486    ];
487    const PAYLOADS: &[&str] = &[
488        "| sh",
489        "| bash",
490        "|sh",
491        "|bash",
492        "rm -rf",
493        "base64 -d",
494        "base64 --decode",
495        "<script",
496        "javascript:",
497        "chmod +x",
498        "eval(",
499        "/etc/passwd",
500        ".ssh/",
501    ];
502    if STEERING.iter().any(|p| t.contains(p)) {
503        return Some("reads like instructions to an agent");
504    }
505    if PAYLOADS.iter().any(|p| t.contains(p)) {
506        return Some("contains a shell or script payload");
507    }
508    None
509}
510
511/// Secrets and invisible/bidi characters never enter the shared ledger.
512pub fn suspicious(text: &str) -> Option<&'static str> {
513    if text
514        .chars()
515        .any(|c| matches!(c as u32, 0x202A..=0x202E | 0x2066..=0x2069 | 0xE0000..=0xE007F))
516    {
517        return Some("contains invisible or bidirectional control characters");
518    }
519    let lower = text.to_ascii_lowercase();
520    let markers = [
521        "-----begin",
522        "aws_secret_access_key",
523        "xoxb-",
524        "ghp_",
525        "glpat-",
526        "sk-ant-",
527        "password=",
528    ];
529    if markers.iter().any(|m| lower.contains(m))
530        || text.split_whitespace().any(|w| {
531            w.starts_with("AKIA") && w.len() == 20 && w.chars().all(|c| c.is_ascii_alphanumeric())
532        })
533    {
534        return Some("looks like it contains a secret");
535    }
536    None
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct Signature {
541    /// Public key, `ssh-ed25519 AAAA…`.
542    pub key: String,
543    /// Armored SSHSIG over the record without its signature.
544    pub sig: String,
545}
546
547/// One review of a record.
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct Event {
550    pub id: String,
551    /// `confirm`, `supersede`, `dispute`, `retract`, `archive` or `invalidate`.
552    pub action: String,
553    pub who: String,
554    pub at: String,
555    pub reason: String,
556    /// False when it could not take effect (a machine retiring a human-backed record).
557    pub counted: bool,
558}