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 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 Harvested,
90 Distilled,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct Provenance {
95 pub author: AuthorKind,
96 pub actor: String,
98 pub source: String,
99 #[serde(default)]
100 pub agent: Option<String>,
101 #[serde(default)]
102 pub session: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub name: Option<String>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub teams: Vec<String>,
109}
110
111#[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 pub fn who(&self) -> String {
141 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#[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 #[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 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#[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 #[serde(default)]
210 pub trigger: Option<String>,
211 #[serde(default)]
213 pub fields: BTreeMap<String, serde_json::Value>,
214 pub anchors: Vec<Anchor>,
215 #[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 pub created: String,
225 #[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 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 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 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 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 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 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 pub fn validate(&self) -> Result<()> {
335 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 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 #[serde(alias = "moved", alias = "stale")]
387 Active,
388 Orphaned,
390 Superseded,
391 Retracted,
392}
393
394#[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#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct Entry {
408 pub record: Record,
409 pub status: Status,
410 pub trust: Trust,
411 pub current: Vec<String>,
413 pub related: Vec<(RelationKind, String)>,
415 pub verified: Option<bool>,
417 pub by: String,
419 pub history: Vec<Event>,
421 pub retired: Option<String>,
423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
425 pub outside: Vec<String>,
426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 pub confirmers: Vec<String>,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
431 pub vouched: Vec<Anchor>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub alike: Option<Alike>,
435 #[serde(default, skip_serializing_if = "Vec::is_empty")]
437 pub notes: Vec<Note>,
438 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
440 pub landed: std::collections::BTreeMap<String, Vec<String>>,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445pub struct Note {
446 pub what: String,
447 pub name: String,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub now: Option<String>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub renamed: Option<String>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub since: Option<String>,
458}
459
460impl Note {
461 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 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#[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
506pub 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
524pub 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
567pub 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 pub key: String,
599 pub sig: String,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct Event {
606 pub id: String,
607 pub action: String,
609 pub who: String,
610 pub at: String,
611 pub reason: String,
612 pub counted: bool,
614}