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        match (self.author, self.name.as_deref(), self.agent.as_deref()) {
142            (AuthorKind::Agent, Some(n), Some(a)) => format!("{a} for {n}"),
143            (AuthorKind::Agent, Some(n), None) => format!("an agent for {n}"),
144            (AuthorKind::Agent, None, Some(a)) => a.to_string(),
145            (AuthorKind::Agent, None, None) => "an agent".into(),
146            (AuthorKind::Human, Some(n), _) => n.to_string(),
147            (AuthorKind::Human, None, _) => "a person".into(),
148            (AuthorKind::Harvested, ..) => "git history".into(),
149            (AuthorKind::Distilled, ..) => "the distiller".into(),
150        }
151    }
152}
153
154/// Where a record points: a symbol as it was when the record was written.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct Anchor {
157    pub symbol: String,
158    pub repo: String,
159    pub path: String,
160    pub name: String,
161    pub line: u32,
162    #[serde(default)]
163    pub fingerprint: Option<Fingerprint>,
164    /// The indexed commit of the anchor's repository when the record was written.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub commit: Option<String>,
167}
168
169impl Anchor {
170    pub fn of(s: &Symbol) -> Anchor {
171        Anchor {
172            symbol: s.id.render(),
173            repo: s.repo.0.clone(),
174            path: s.file_path.clone(),
175            name: s.name.clone(),
176            line: s.range.start.line,
177            fingerprint: s.fingerprint,
178            commit: None,
179        }
180    }
181
182    /// With the commit `commits` (by repository) records for the anchor's repository.
183    pub fn at(mut self, commits: &std::collections::HashMap<String, String>) -> Anchor {
184        self.commit = commits.get(&self.repo).cloned();
185        self
186    }
187}
188
189/// Code that shaped a record, as it was at `commit`.
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct Snippet {
192    pub path: String,
193    pub code: String,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub commit: Option<String>,
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct Record {
200    pub id: String,
201    pub schema: u32,
202    pub kind: Kind,
203    pub title: String,
204    #[serde(default)]
205    pub body: String,
206    /// When a future reader needs this ("relevant when adding caching to auth paths").
207    #[serde(default)]
208    pub trigger: Option<String>,
209    /// Kind-specific fields (`alternatives`, `outcome`, `rule`, `severity`, …).
210    #[serde(default)]
211    pub fields: BTreeMap<String, serde_json::Value>,
212    pub anchors: Vec<Anchor>,
213    /// `commit:<sha>`, `mr:!412`, `test:<path>::<name>`, `url:…`, or free text.
214    #[serde(default)]
215    pub evidence: Vec<String>,
216    #[serde(default, skip_serializing_if = "Vec::is_empty")]
217    pub snippets: Vec<Snippet>,
218    #[serde(default)]
219    pub relation: Option<Relation>,
220    pub provenance: Provenance,
221    /// RFC 3339, UTC.
222    pub created: String,
223    /// Optional SSH signature over the rest of the record .
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub signature: Option<Signature>,
226}
227
228impl Record {
229    pub fn new(kind: Kind, title: &str, anchors: Vec<Anchor>, provenance: Provenance) -> Record {
230        Record {
231            id: uuid::Uuid::now_v7().to_string(),
232            schema: SCHEMA,
233            kind,
234            title: title.trim().to_string(),
235            body: String::new(),
236            trigger: None,
237            fields: BTreeMap::new(),
238            anchors,
239            evidence: Vec::new(),
240            snippets: Vec::new(),
241            relation: None,
242            provenance,
243            created: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
244            signature: None,
245        }
246    }
247
248    /// The relation record for a review `action` (see [`review_action`]) on `target`.
249    pub fn review(
250        target: &Record,
251        action: &str,
252        reason: &str,
253        provenance: Provenance,
254    ) -> Result<Record> {
255        let (kind, retire) = review_action(action).with_context(|| {
256            format!("unknown action `{action}`: confirm, archive, invalidate, supersede, dispute or retract")
257        })?;
258        let mut r = Record::relation(kind, target, reason, provenance);
259        if let Some(why) = retire {
260            r.fields
261                .insert("retire".into(), serde_json::Value::String(why.into()));
262        }
263        Ok(r)
264    }
265
266    /// A relation record (`confirm`, `retract`, …) about `target`.
267    pub fn relation(
268        kind: RelationKind,
269        target: &Record,
270        reason: &str,
271        provenance: Provenance,
272    ) -> Record {
273        let title = if reason.trim().is_empty() {
274            format!("{kind:?}: {}", target.title)
275        } else {
276            reason.trim().to_string()
277        };
278        let mut r = Record::new(Kind::Relation, &title, Vec::new(), provenance);
279        r.relation = Some(Relation {
280            kind,
281            target: target.id.clone(),
282        });
283        r
284    }
285
286    /// All free text of a record, for content checks.
287    fn text(&self) -> String {
288        let fields: Vec<String> = self.fields.values().map(|v| v.to_string()).collect();
289        let code: Vec<&str> = self.snippets.iter().map(|s| s.code.as_str()).collect();
290        format!(
291            "{} {} {} {} {} {}",
292            self.title,
293            self.body,
294            self.trigger.as_deref().unwrap_or(""),
295            self.evidence.join(" "),
296            fields.join(" "),
297            code.join(" ")
298        )
299    }
300
301    /// A record that slipped past write-time checks (planted straight into the store).
302    pub fn unsafe_content(&self) -> bool {
303        let t = self.text();
304        suspicious(&t).is_some()
305            || (self.provenance.author != AuthorKind::Human && injection(&t).is_some())
306    }
307
308    /// Reject records that would mislead or carry secrets; caps keep them reviewable.
309    pub fn validate(&self) -> Result<()> {
310        if self.title.is_empty() || self.title.chars().count() > 160 {
311            bail!("title must be 1–160 characters");
312        }
313        if self.body.chars().count() > 4000 {
314            bail!("body is limited to 4000 characters");
315        }
316        if self.kind != Kind::Relation && self.anchors.is_empty() {
317            bail!("a record needs at least one anchor (a symbol it is about)");
318        }
319        if self.fields.contains_key("check") && self.kind != Kind::Constraint {
320            bail!("only constraints carry a check");
321        }
322        if self.snippets.len() > MAX_SNIPPETS {
323            bail!("at most {MAX_SNIPPETS} snippets");
324        }
325        if self.snippets.iter().any(|s| {
326            s.path.trim().is_empty()
327                || s.code.trim().is_empty()
328                || s.code.lines().count() > MAX_SNIPPET_LINES
329                || s.code.chars().count() > 4000
330        }) {
331            bail!("a snippet needs a path and 1–{MAX_SNIPPET_LINES} lines of code (4000 characters at most)");
332        }
333        if self.kind == Kind::Attempt && self.evidence.is_empty() {
334            bail!("an attempt needs evidence (a revert commit, failing test, error, MR, …)");
335        }
336        let text = self.text();
337        if let Some(why) = suspicious(&text) {
338            bail!("refusing to store record: {why}");
339        }
340        // Ledger text is injected into agents' context: machine-written records must not carry
341        // instructions aimed at an agent or shell payloads.
342        if self.provenance.author != AuthorKind::Human {
343            if let Some(why) = injection(&text) {
344                bail!("refusing to store record: {why}");
345            }
346        }
347        Ok(())
348    }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
352#[serde(rename_all = "snake_case")]
353pub enum Status {
354    Active,
355    /// The anchored symbol moved or was renamed; its body is unchanged.
356    Moved,
357    /// The anchored symbol's body changed since the record was written: re-verify.
358    Stale,
359    /// The anchored symbol no longer exists.
360    Orphaned,
361    Superseded,
362    Retracted,
363}
364
365/// T4 constraint enforced by a passing check, T3 human or human-confirmed, T2 agent with evidence,
366/// T1 agent assertion, T0 untrusted.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
368pub enum Trust {
369    T0,
370    T1,
371    T2,
372    T3,
373    T4,
374}
375
376/// A record with its derived state against the current index.
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct Entry {
379    pub record: Record,
380    pub status: Status,
381    pub trust: Trust,
382    /// Current symbol ids the record's anchors map to (after moves/renames).
383    pub current: Vec<String>,
384    /// Ids of records that supersede, retract, confirm or dispute this one.
385    pub related: Vec<(RelationKind, String)>,
386    /// Signature check: verified, failed (record treated as T0), or unsigned/unchecked.
387    pub verified: Option<bool>,
388    /// Who wrote it, for people to read.
389    pub by: String,
390    /// Reviews of this record, oldest first.
391    pub history: Vec<Event>,
392    /// Why it was retracted: `outdated` (archived) or `wrong` (invalidated).
393    pub retired: Option<String>,
394    /// The writer's teams, when the note is about another team's code and none of its members confirmed it.
395    #[serde(default, skip_serializing_if = "Vec::is_empty")]
396    pub outside: Vec<String>,
397}
398
399pub const SCHEMA: u32 = 1;
400
401/// A review action: `confirm`, `supersede`, `dispute`, `retract`, `archive` (no longer applies)
402/// or `invalidate` (was wrong).
403pub fn review_action(action: &str) -> Option<(RelationKind, Option<&'static str>)> {
404    match action {
405        "archive" => Some((RelationKind::Retracts, Some("outdated"))),
406        "invalidate" => Some((RelationKind::Retracts, Some("wrong"))),
407        other => RelationKind::parse(other).map(|k| (k, None)),
408    }
409}
410
411pub const MAX_SNIPPETS: usize = 3;
412
413pub const MAX_SNIPPET_LINES: usize = 60;
414
415/// Instruction or payload text that would steer an agent reading the record.
416pub fn injection(text: &str) -> Option<&'static str> {
417    let t = text.to_lowercase();
418    const STEERING: &[&str] = &[
419        "ignore previous",
420        "ignore all previous",
421        "ignore the above",
422        "disregard previous",
423        "disregard the above",
424        "system prompt",
425        "you are now",
426        "new instructions",
427        "<system",
428        "</system",
429        "<instructions",
430        "assistant:",
431        "do not tell the user",
432        "without telling the user",
433    ];
434    const PAYLOADS: &[&str] = &[
435        "| sh",
436        "| bash",
437        "|sh",
438        "|bash",
439        "rm -rf",
440        "base64 -d",
441        "base64 --decode",
442        "<script",
443        "javascript:",
444        "chmod +x",
445        "eval(",
446        "/etc/passwd",
447        ".ssh/",
448    ];
449    if STEERING.iter().any(|p| t.contains(p)) {
450        return Some("reads like instructions to an agent");
451    }
452    if PAYLOADS.iter().any(|p| t.contains(p)) {
453        return Some("contains a shell or script payload");
454    }
455    None
456}
457
458/// Secrets and invisible/bidi characters never enter the shared ledger.
459pub fn suspicious(text: &str) -> Option<&'static str> {
460    if text
461        .chars()
462        .any(|c| matches!(c as u32, 0x202A..=0x202E | 0x2066..=0x2069 | 0xE0000..=0xE007F))
463    {
464        return Some("contains invisible or bidirectional control characters");
465    }
466    let lower = text.to_ascii_lowercase();
467    let markers = [
468        "-----begin",
469        "aws_secret_access_key",
470        "xoxb-",
471        "ghp_",
472        "glpat-",
473        "sk-ant-",
474        "password=",
475    ];
476    if markers.iter().any(|m| lower.contains(m))
477        || text.split_whitespace().any(|w| {
478            w.starts_with("AKIA") && w.len() == 20 && w.chars().all(|c| c.is_ascii_alphanumeric())
479        })
480    {
481        return Some("looks like it contains a secret");
482    }
483    None
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct Signature {
488    /// Public key, `ssh-ed25519 AAAA…`.
489    pub key: String,
490    /// Armored SSHSIG over the record without its signature.
491    pub sig: String,
492}
493
494/// One review of a record.
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct Event {
497    pub id: String,
498    /// `confirm`, `supersede`, `dispute`, `retract`, `archive` or `invalidate`.
499    pub action: String,
500    pub who: String,
501    pub at: String,
502    pub reason: String,
503    /// False when it could not take effect (a machine retiring a human-backed record).
504    pub counted: bool,
505}