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 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#[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 #[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 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#[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 #[serde(default)]
208 pub trigger: Option<String>,
209 #[serde(default)]
211 pub fields: BTreeMap<String, serde_json::Value>,
212 pub anchors: Vec<Anchor>,
213 #[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 pub created: String,
223 #[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 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 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 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 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 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 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 Moved,
357 Stale,
359 Orphaned,
361 Superseded,
362 Retracted,
363}
364
365#[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#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct Entry {
379 pub record: Record,
380 pub status: Status,
381 pub trust: Trust,
382 pub current: Vec<String>,
384 pub related: Vec<(RelationKind, String)>,
386 pub verified: Option<bool>,
388 pub by: String,
390 pub history: Vec<Event>,
392 pub retired: Option<String>,
394 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 pub outside: Vec<String>,
397}
398
399pub const SCHEMA: u32 = 1;
400
401pub 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
415pub 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
458pub 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 pub key: String,
490 pub sig: String,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct Event {
497 pub id: String,
498 pub action: String,
500 pub who: String,
501 pub at: String,
502 pub reason: String,
503 pub counted: bool,
505}