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        // A review keeps its reason in the title.
336        let (cap, what) = if self.kind == Kind::Relation {
337            (MAX_REASON, "a reason")
338        } else {
339            (MAX_TITLE, "a title")
340        };
341        if self.title.is_empty() || self.title.chars().count() > cap {
342            bail!("{what} must be 1–{cap} characters");
343        }
344        if self.body.chars().count() > MAX_BODY {
345            bail!("the body is limited to {MAX_BODY} characters");
346        }
347        if self.kind != Kind::Relation && self.anchors.is_empty() {
348            bail!("a record needs at least one anchor (a symbol it is about)");
349        }
350        if self.fields.contains_key("check") && self.kind != Kind::Constraint {
351            bail!("only constraints carry a check");
352        }
353        if self.snippets.len() > MAX_SNIPPETS {
354            bail!("at most {MAX_SNIPPETS} snippets");
355        }
356        if self.snippets.iter().any(|s| {
357            s.path.trim().is_empty()
358                || s.code.trim().is_empty()
359                || s.code.lines().count() > MAX_SNIPPET_LINES
360                || s.code.chars().count() > MAX_SNIPPET_CHARS
361        }) {
362            bail!("a snippet needs a path and 1–{MAX_SNIPPET_LINES} lines of code ({MAX_SNIPPET_CHARS} characters at most)");
363        }
364        if self.kind == Kind::Attempt && self.evidence.is_empty() {
365            bail!("an attempt needs evidence (a revert commit, failing test, error, MR, …)");
366        }
367        let text = self.text();
368        if let Some(why) = suspicious(&text) {
369            bail!("refusing to store record: {why}");
370        }
371        // Ledger text is injected into agents' context: machine-written records must not carry
372        // instructions aimed at an agent or shell payloads.
373        if self.provenance.author != AuthorKind::Human {
374            if let Some(why) = injection(&text) {
375                bail!("refusing to store record: {why}");
376            }
377        }
378        Ok(())
379    }
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
383#[serde(rename_all = "snake_case")]
384pub enum Status {
385    /// Standing until someone retires it; code changes only add notes. Older engines said moved or stale.
386    #[serde(alias = "moved", alias = "stale")]
387    Active,
388    /// None of the anchored symbols exist any more.
389    Orphaned,
390    Superseded,
391    Retracted,
392}
393
394/// T4 constraint enforced by a passing check, T3 human or human-confirmed, T2 agent with evidence,
395/// T1 agent assertion, T0 untrusted.
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
397pub enum Trust {
398    T0,
399    T1,
400    T2,
401    T3,
402    T4,
403}
404
405/// A record with its derived state against the current index.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct Entry {
408    pub record: Record,
409    pub status: Status,
410    pub trust: Trust,
411    /// Current symbol ids the record's anchors map to (after moves/renames).
412    pub current: Vec<String>,
413    /// Ids of records that supersede, retract, confirm or dispute this one.
414    pub related: Vec<(RelationKind, String)>,
415    /// Signature check: verified, failed (record treated as T0), or unsigned/unchecked.
416    pub verified: Option<bool>,
417    /// Who wrote it, for people to read.
418    pub by: String,
419    /// Reviews of this record, oldest first.
420    pub history: Vec<Event>,
421    /// Why it was retracted: `outdated` (archived) or `wrong` (invalidated).
422    pub retired: Option<String>,
423    /// The writer's teams, when the note is about another team's code and none of its members confirmed it.
424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
425    pub outside: Vec<String>,
426    /// People other than the writer who confirmed it, themselves or through their agents.
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub confirmers: Vec<String>,
429    /// Where the code is now, when a person's confirmation re-anchored the record.
430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
431    pub vouched: Vec<Anchor>,
432    /// An older standing record this one reads like, until a person keeps or retires it.
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub alike: Option<Alike>,
435    /// What happened to the anchored code since the record, or its latest confirmation, saw it.
436    #[serde(default, skip_serializing_if = "Vec::is_empty")]
437    pub notes: Vec<Note>,
438    /// Evidence (a merge or pull request, a branch commit) and the default-branch commits it became.
439    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
440    pub landed: std::collections::BTreeMap<String, Vec<String>>,
441}
442
443/// A change to one anchored symbol: `changed`, `moved` (another file), `renamed` or `gone`.
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445pub struct Note {
446    pub what: String,
447    /// The symbol's name when the record saw it.
448    pub name: String,
449    /// Where it is now (`path:line`); absent when gone.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub now: Option<String>,
452    /// Its name now, when renamed.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub renamed: Option<String>,
455    /// The indexed commit the record saw, to compare against.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub since: Option<String>,
458}
459
460impl Note {
461    /// One line for agents and people: "`merge` changed since 0cdb88b, now at app/x.py:40".
462    pub fn text(&self) -> String {
463        let since = self
464            .since
465            .as_deref()
466            .map(|c| format!(" since {}", &c[..c.len().min(8)]))
467            .unwrap_or_default();
468        let now = self
469            .now
470            .as_deref()
471            .map(|n| format!(", now at {n}"))
472            .unwrap_or_default();
473        match self.what.as_str() {
474            "renamed" => format!(
475                "`{}` renamed to `{}`{since}{now}",
476                self.name,
477                self.renamed.as_deref().unwrap_or("?")
478            ),
479            "gone" => format!("`{}` no longer exists{since}", self.name),
480            what => format!("`{}` {what}{since}{now}", self.name),
481        }
482    }
483}
484
485impl Entry {
486    /// The anchors to show: where a confirmation last saw the code, else where it was written.
487    pub fn anchors(&self) -> &[Anchor] {
488        if self.vouched.is_empty() {
489            &self.record.anchors
490        } else {
491            &self.vouched
492        }
493    }
494}
495
496/// The older record a record may repeat, and how alike they read (0 to 1).
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct Alike {
499    pub id: String,
500    pub title: String,
501    pub score: f32,
502}
503
504pub const SCHEMA: u32 = 1;
505
506/// A review action: `confirm`, `supersede`, `dispute`, `retract`, `archive` (no longer applies)
507/// or `invalidate` (was wrong).
508pub fn review_action(action: &str) -> Option<(RelationKind, Option<&'static str>)> {
509    match action {
510        "archive" => Some((RelationKind::Retracts, Some("outdated"))),
511        "invalidate" => Some((RelationKind::Retracts, Some("wrong"))),
512        other => RelationKind::parse(other).map(|k| (k, None)),
513    }
514}
515
516pub const MAX_TITLE: usize = 500;
517pub const MAX_REASON: usize = 4000;
518pub const MAX_BODY: usize = 20000;
519pub const MAX_SNIPPET_CHARS: usize = 16000;
520pub const MAX_SNIPPETS: usize = 3;
521
522pub const MAX_SNIPPET_LINES: usize = 60;
523
524/// Instruction or payload text that would steer an agent reading the record.
525pub fn injection(text: &str) -> Option<&'static str> {
526    let t = text.to_lowercase();
527    const STEERING: &[&str] = &[
528        "ignore previous",
529        "ignore all previous",
530        "ignore the above",
531        "disregard previous",
532        "disregard the above",
533        "system prompt",
534        "you are now",
535        "new instructions",
536        "<system",
537        "</system",
538        "<instructions",
539        "assistant:",
540        "do not tell the user",
541        "without telling the user",
542    ];
543    const PAYLOADS: &[&str] = &[
544        "| sh",
545        "| bash",
546        "|sh",
547        "|bash",
548        "rm -rf",
549        "base64 -d",
550        "base64 --decode",
551        "<script",
552        "javascript:",
553        "chmod +x",
554        "eval(",
555        "/etc/passwd",
556        ".ssh/",
557    ];
558    if STEERING.iter().any(|p| t.contains(p)) {
559        return Some("reads like instructions to an agent");
560    }
561    if PAYLOADS.iter().any(|p| t.contains(p)) {
562        return Some("contains a shell or script payload");
563    }
564    None
565}
566
567/// Secrets and invisible/bidi characters never enter the shared ledger.
568pub fn suspicious(text: &str) -> Option<&'static str> {
569    if text
570        .chars()
571        .any(|c| matches!(c as u32, 0x202A..=0x202E | 0x2066..=0x2069 | 0xE0000..=0xE007F))
572    {
573        return Some("contains invisible or bidirectional control characters");
574    }
575    let lower = text.to_ascii_lowercase();
576    let markers = [
577        "-----begin",
578        "aws_secret_access_key",
579        "xoxb-",
580        "ghp_",
581        "glpat-",
582        "sk-ant-",
583        "password=",
584    ];
585    if markers.iter().any(|m| lower.contains(m))
586        || text.split_whitespace().any(|w| {
587            w.starts_with("AKIA") && w.len() == 20 && w.chars().all(|c| c.is_ascii_alphanumeric())
588        })
589    {
590        return Some("looks like it contains a secret");
591    }
592    None
593}
594
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
596pub struct Signature {
597    /// Public key, `ssh-ed25519 AAAA…`.
598    pub key: String,
599    /// Armored SSHSIG over the record without its signature.
600    pub sig: String,
601}
602
603/// One review of a record.
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct Event {
606    pub id: String,
607    /// `confirm`, `supersede`, `dispute`, `retract`, `archive` or `invalidate`.
608    pub action: String,
609    pub who: String,
610    pub at: String,
611    pub reason: String,
612    /// False when it could not take effect (a machine retiring a human-backed record).
613    pub counted: bool,
614}