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        let mut r = Record::relation(kind, target, reason, provenance);
261        if let Some(why) = retire {
262            r.fields
263                .insert("retire".into(), serde_json::Value::String(why.into()));
264        }
265        Ok(r)
266    }
267
268    /// A confirmation vouches for the code as indexed now: it takes the target's anchors from `now`.
269    pub fn vouching(mut self, target: &Record, now: impl Fn(&str) -> Option<Anchor>) -> Record {
270        if self.relation.as_ref().map(|rel| rel.kind) == Some(RelationKind::Confirms) {
271            self.anchors = target
272                .anchors
273                .iter()
274                .filter_map(|a| now(&a.symbol))
275                .collect();
276        }
277        self
278    }
279
280    /// A relation record (`confirm`, `retract`, …) about `target`.
281    pub fn relation(
282        kind: RelationKind,
283        target: &Record,
284        reason: &str,
285        provenance: Provenance,
286    ) -> Record {
287        let title = if reason.trim().is_empty() {
288            format!("{kind:?}: {}", target.title)
289        } else {
290            reason.trim().to_string()
291        };
292        let mut r = Record::new(Kind::Relation, &title, Vec::new(), provenance);
293        r.relation = Some(Relation {
294            kind,
295            target: target.id.clone(),
296        });
297        r
298    }
299
300    /// All free text of a record, for content checks.
301    fn text(&self) -> String {
302        let fields: Vec<String> = self.fields.values().map(|v| v.to_string()).collect();
303        let code: Vec<&str> = self.snippets.iter().map(|s| s.code.as_str()).collect();
304        format!(
305            "{} {} {} {} {} {}",
306            self.title,
307            self.body,
308            self.trigger.as_deref().unwrap_or(""),
309            self.evidence.join(" "),
310            fields.join(" "),
311            code.join(" ")
312        )
313    }
314
315    /// A record that slipped past write-time checks (planted straight into the store).
316    pub fn unsafe_content(&self) -> bool {
317        let t = self.text();
318        suspicious(&t).is_some()
319            || (self.provenance.author != AuthorKind::Human && injection(&t).is_some())
320    }
321
322    /// Reject records that would mislead or carry secrets; caps keep them reviewable.
323    pub fn validate(&self) -> Result<()> {
324        if self.title.is_empty() || self.title.chars().count() > 160 {
325            bail!("title must be 1–160 characters");
326        }
327        if self.body.chars().count() > 4000 {
328            bail!("body is limited to 4000 characters");
329        }
330        if self.kind != Kind::Relation && self.anchors.is_empty() {
331            bail!("a record needs at least one anchor (a symbol it is about)");
332        }
333        if self.fields.contains_key("check") && self.kind != Kind::Constraint {
334            bail!("only constraints carry a check");
335        }
336        if self.snippets.len() > MAX_SNIPPETS {
337            bail!("at most {MAX_SNIPPETS} snippets");
338        }
339        if self.snippets.iter().any(|s| {
340            s.path.trim().is_empty()
341                || s.code.trim().is_empty()
342                || s.code.lines().count() > MAX_SNIPPET_LINES
343                || s.code.chars().count() > 4000
344        }) {
345            bail!("a snippet needs a path and 1–{MAX_SNIPPET_LINES} lines of code (4000 characters at most)");
346        }
347        if self.kind == Kind::Attempt && self.evidence.is_empty() {
348            bail!("an attempt needs evidence (a revert commit, failing test, error, MR, …)");
349        }
350        let text = self.text();
351        if let Some(why) = suspicious(&text) {
352            bail!("refusing to store record: {why}");
353        }
354        // Ledger text is injected into agents' context: machine-written records must not carry
355        // instructions aimed at an agent or shell payloads.
356        if self.provenance.author != AuthorKind::Human {
357            if let Some(why) = injection(&text) {
358                bail!("refusing to store record: {why}");
359            }
360        }
361        Ok(())
362    }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
366#[serde(rename_all = "snake_case")]
367pub enum Status {
368    Active,
369    /// The anchored symbol moved or was renamed; its body is unchanged.
370    Moved,
371    /// The anchored symbol's body changed since the record was written: re-verify.
372    Stale,
373    /// The anchored symbol no longer exists.
374    Orphaned,
375    Superseded,
376    Retracted,
377}
378
379/// T4 constraint enforced by a passing check, T3 human or human-confirmed, T2 agent with evidence,
380/// T1 agent assertion, T0 untrusted.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
382pub enum Trust {
383    T0,
384    T1,
385    T2,
386    T3,
387    T4,
388}
389
390/// A record with its derived state against the current index.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct Entry {
393    pub record: Record,
394    pub status: Status,
395    pub trust: Trust,
396    /// Current symbol ids the record's anchors map to (after moves/renames).
397    pub current: Vec<String>,
398    /// Ids of records that supersede, retract, confirm or dispute this one.
399    pub related: Vec<(RelationKind, String)>,
400    /// Signature check: verified, failed (record treated as T0), or unsigned/unchecked.
401    pub verified: Option<bool>,
402    /// Who wrote it, for people to read.
403    pub by: String,
404    /// Reviews of this record, oldest first.
405    pub history: Vec<Event>,
406    /// Why it was retracted: `outdated` (archived) or `wrong` (invalidated).
407    pub retired: Option<String>,
408    /// The writer's teams, when the note is about another team's code and none of its members confirmed it.
409    #[serde(default, skip_serializing_if = "Vec::is_empty")]
410    pub outside: Vec<String>,
411    /// Where the code is now, when a person's confirmation re-anchored the record.
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub vouched: Vec<Anchor>,
414    /// An older standing record this one reads like, until a person keeps or retires it.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub alike: Option<Alike>,
417}
418
419impl Entry {
420    /// The anchors to show: where a confirmation last saw the code, else where it was written.
421    pub fn anchors(&self) -> &[Anchor] {
422        if self.vouched.is_empty() {
423            &self.record.anchors
424        } else {
425            &self.vouched
426        }
427    }
428}
429
430/// The older record a record may repeat, and how alike they read (0 to 1).
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct Alike {
433    pub id: String,
434    pub title: String,
435    pub score: f32,
436}
437
438pub const SCHEMA: u32 = 1;
439
440/// A review action: `confirm`, `supersede`, `dispute`, `retract`, `archive` (no longer applies)
441/// or `invalidate` (was wrong).
442pub fn review_action(action: &str) -> Option<(RelationKind, Option<&'static str>)> {
443    match action {
444        "archive" => Some((RelationKind::Retracts, Some("outdated"))),
445        "invalidate" => Some((RelationKind::Retracts, Some("wrong"))),
446        other => RelationKind::parse(other).map(|k| (k, None)),
447    }
448}
449
450pub const MAX_SNIPPETS: usize = 3;
451
452pub const MAX_SNIPPET_LINES: usize = 60;
453
454/// Instruction or payload text that would steer an agent reading the record.
455pub fn injection(text: &str) -> Option<&'static str> {
456    let t = text.to_lowercase();
457    const STEERING: &[&str] = &[
458        "ignore previous",
459        "ignore all previous",
460        "ignore the above",
461        "disregard previous",
462        "disregard the above",
463        "system prompt",
464        "you are now",
465        "new instructions",
466        "<system",
467        "</system",
468        "<instructions",
469        "assistant:",
470        "do not tell the user",
471        "without telling the user",
472    ];
473    const PAYLOADS: &[&str] = &[
474        "| sh",
475        "| bash",
476        "|sh",
477        "|bash",
478        "rm -rf",
479        "base64 -d",
480        "base64 --decode",
481        "<script",
482        "javascript:",
483        "chmod +x",
484        "eval(",
485        "/etc/passwd",
486        ".ssh/",
487    ];
488    if STEERING.iter().any(|p| t.contains(p)) {
489        return Some("reads like instructions to an agent");
490    }
491    if PAYLOADS.iter().any(|p| t.contains(p)) {
492        return Some("contains a shell or script payload");
493    }
494    None
495}
496
497/// Secrets and invisible/bidi characters never enter the shared ledger.
498pub fn suspicious(text: &str) -> Option<&'static str> {
499    if text
500        .chars()
501        .any(|c| matches!(c as u32, 0x202A..=0x202E | 0x2066..=0x2069 | 0xE0000..=0xE007F))
502    {
503        return Some("contains invisible or bidirectional control characters");
504    }
505    let lower = text.to_ascii_lowercase();
506    let markers = [
507        "-----begin",
508        "aws_secret_access_key",
509        "xoxb-",
510        "ghp_",
511        "glpat-",
512        "sk-ant-",
513        "password=",
514    ];
515    if markers.iter().any(|m| lower.contains(m))
516        || text.split_whitespace().any(|w| {
517            w.starts_with("AKIA") && w.len() == 20 && w.chars().all(|c| c.is_ascii_alphanumeric())
518        })
519    {
520        return Some("looks like it contains a secret");
521    }
522    None
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub struct Signature {
527    /// Public key, `ssh-ed25519 AAAA…`.
528    pub key: String,
529    /// Armored SSHSIG over the record without its signature.
530    pub sig: String,
531}
532
533/// One review of a record.
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct Event {
536    pub id: String,
537    /// `confirm`, `supersede`, `dispute`, `retract`, `archive` or `invalidate`.
538    pub action: String,
539    pub who: String,
540    pub at: String,
541    pub reason: String,
542    /// False when it could not take effect (a machine retiring a human-backed record).
543    pub counted: bool,
544}