1use serde::{Deserialize, Serialize};
2use std::fmt::{Display, Formatter};
3use std::str::FromStr;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::error::{KernelError, Result};
7use crate::principal::AGENT_LOCATOR_PREFIX;
8use crate::secrets::contains_secret;
9
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
11#[serde(rename_all = "snake_case")]
12pub enum TraceKind {
13 Observation,
14 Episode,
15 Belief,
16 Procedure,
17 Commitment,
18 Preference,
19 Constraint,
20 Outcome,
21 Correction,
22 Prediction,
26}
27
28impl Display for TraceKind {
29 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
30 let value = match self {
31 Self::Observation => "observation",
32 Self::Episode => "episode",
33 Self::Belief => "belief",
34 Self::Procedure => "procedure",
35 Self::Commitment => "commitment",
36 Self::Preference => "preference",
37 Self::Constraint => "constraint",
38 Self::Outcome => "outcome",
39 Self::Correction => "correction",
40 Self::Prediction => "prediction",
41 };
42 f.write_str(value)
43 }
44}
45
46impl FromStr for TraceKind {
47 type Err = KernelError;
48
49 fn from_str(value: &str) -> Result<Self> {
50 match value.trim().to_ascii_lowercase().as_str() {
51 "observation" => Ok(Self::Observation),
52 "episode" => Ok(Self::Episode),
53 "belief" => Ok(Self::Belief),
54 "procedure" => Ok(Self::Procedure),
55 "commitment" => Ok(Self::Commitment),
56 "preference" => Ok(Self::Preference),
57 "constraint" => Ok(Self::Constraint),
58 "outcome" => Ok(Self::Outcome),
59 "correction" => Ok(Self::Correction),
60 "prediction" => Ok(Self::Prediction),
61 other => Err(KernelError::Invalid(format!("unknown trace kind: {other}"))),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum PrivacyClass {
69 Public,
70 #[default]
71 Private,
72 Sensitive,
73}
74
75impl FromStr for PrivacyClass {
76 type Err = KernelError;
77
78 fn from_str(value: &str) -> Result<Self> {
79 match value.trim().to_ascii_lowercase().as_str() {
80 "public" => Ok(Self::Public),
81 "private" => Ok(Self::Private),
82 "sensitive" => Ok(Self::Sensitive),
83 other => Err(KernelError::Invalid(format!(
84 "unknown privacy class: {other}"
85 ))),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
91#[serde(rename_all = "snake_case")]
92pub enum TraceStatus {
93 Active,
94 Superseded,
95 Fulfilled,
96 Purged,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct Provenance {
101 pub source: String,
102 pub locator: Option<String>,
103 pub observed_at_ms: u64,
104 pub authority: f32,
105 pub verified: bool,
106}
107
108impl Provenance {
109 pub fn new(source: impl Into<String>) -> Self {
110 Self {
111 source: source.into(),
112 locator: None,
113 observed_at_ms: now_ms(),
114 authority: 0.5,
115 verified: false,
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct Trace {
122 pub id: String,
123 pub kind: TraceKind,
124 pub content: String,
125 pub tags: Vec<String>,
126 pub created_at_ms: u64,
127 pub deadline_ms: Option<u64>,
128 pub salience: f32,
129 pub confidence: f32,
130 pub privacy: PrivacyClass,
131 pub provenance: Provenance,
132 pub claim_key: Option<String>,
133 pub claim_value: Option<String>,
134 pub supersedes: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub prediction: Option<PredictionRecord>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub outcome: Option<OutcomeRecord>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub struct PredictionRecord {
147 pub request_id: String,
148 pub question_id: String,
149 pub family: String,
151 pub answer_kind: String,
153 pub engine: String,
156 pub p: Option<f64>,
159 pub value: String,
161 #[serde(default)]
162 pub probabilities: std::collections::BTreeMap<String, f64>,
163 #[serde(default)]
165 pub answer_space: Vec<String>,
166 pub calibrated: bool,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct OutcomeRecord {
173 pub prediction_id: String,
174 pub observed: String,
176}
177
178pub const MODEL_SOURCE_PREFIX: &str = "model:";
180
181impl Trace {
182 pub fn new(kind: TraceKind, content: impl Into<String>, source: impl Into<String>) -> Self {
183 Self {
184 id: String::new(),
185 kind,
186 content: content.into(),
187 tags: Vec::new(),
188 created_at_ms: now_ms(),
189 deadline_ms: None,
190 salience: 0.5,
191 confidence: 0.5,
192 privacy: PrivacyClass::Private,
193 provenance: Provenance::new(source),
194 claim_key: None,
195 claim_value: None,
196 supersedes: None,
197 prediction: None,
198 outcome: None,
199 }
200 }
201
202 pub fn is_model_authored(&self) -> bool {
204 self.provenance
205 .source
206 .trim()
207 .to_ascii_lowercase()
208 .starts_with(MODEL_SOURCE_PREFIX)
209 }
210
211 pub fn is_from_agent_session(&self) -> bool {
214 self.provenance
215 .locator
216 .as_deref()
217 .is_some_and(|locator| locator.trim_start().starts_with(AGENT_LOCATOR_PREFIX))
218 }
219
220 pub fn validate(&self) -> Result<()> {
221 if self.content.trim().is_empty() {
222 return Err(KernelError::Invalid("trace content cannot be empty".into()));
223 }
224 self.refuse_secrets()?;
225 if !(0.0..=1.0).contains(&self.salience) {
226 return Err(KernelError::Invalid(
227 "salience must be between 0 and 1".into(),
228 ));
229 }
230 if !(0.0..=1.0).contains(&self.confidence) {
231 return Err(KernelError::Invalid(
232 "confidence must be between 0 and 1".into(),
233 ));
234 }
235 if !(0.0..=1.0).contains(&self.provenance.authority) {
236 return Err(KernelError::Invalid(
237 "provenance authority must be between 0 and 1".into(),
238 ));
239 }
240 if self.claim_key.is_some() != self.claim_value.is_some() {
241 return Err(KernelError::Invalid(
242 "claim_key and claim_value must be supplied together".into(),
243 ));
244 }
245 if self.provenance.source.trim().is_empty() {
246 return Err(KernelError::Invalid(
247 "provenance source cannot be empty".into(),
248 ));
249 }
250 if self.is_model_authored() && self.provenance.verified {
251 return Err(KernelError::Invalid(
252 "model-authored traces cannot be marked verified; record an outcome from a non-model principal instead"
253 .into(),
254 ));
255 }
256 if self.provenance.verified && self.is_from_agent_session() {
257 return Err(KernelError::Invalid(
258 "a trace written from an AI agent session cannot be marked verified; a person must attest it from outside the agent session"
259 .into(),
260 ));
261 }
262 if self.supersedes.as_deref() == Some(self.id.as_str()) && !self.id.is_empty() {
263 return Err(KernelError::Invalid(
264 "a trace cannot supersede itself".into(),
265 ));
266 }
267 match self.kind {
268 TraceKind::Prediction => {
269 let Some(record) = &self.prediction else {
270 return Err(KernelError::Invalid(
271 "prediction traces need a prediction record".into(),
272 ));
273 };
274 if self.provenance.verified {
276 return Err(KernelError::Invalid(
277 "prediction traces cannot be marked verified; record an outcome from a non-model principal instead"
278 .into(),
279 ));
280 }
281 let principal = self.provenance.source.trim();
287 let expected = if self.is_model_authored() {
288 &principal[MODEL_SOURCE_PREFIX.len()..]
289 } else {
290 principal
291 };
292 if record.engine != expected {
293 return Err(KernelError::Invalid(format!(
294 "prediction record engine `{}` must match its source `{principal}`",
295 record.engine
296 )));
297 }
298 if self.supersedes.is_some() {
299 return Err(KernelError::Invalid(
300 "prediction traces cannot supersede other traces".into(),
301 ));
302 }
303 }
304 _ if self.prediction.is_some() => {
305 return Err(KernelError::Invalid(
306 "only prediction traces may carry a prediction record".into(),
307 ));
308 }
309 _ => {}
310 }
311 if self.outcome.is_some() {
312 if self.kind != TraceKind::Outcome {
313 return Err(KernelError::Invalid(
314 "only outcome traces may carry an outcome record".into(),
315 ));
316 }
317 if self.is_model_authored() {
318 return Err(KernelError::Invalid(
319 "outcomes must come from a non-model principal".into(),
320 ));
321 }
322 }
323 Ok(())
324 }
325
326 fn refuse_secrets(&self) -> Result<()> {
330 let mut fields: Vec<(&str, &str)> = vec![
331 ("content", self.content.as_str()),
332 ("source", self.provenance.source.as_str()),
333 ];
334 fields.extend(self.tags.iter().map(|tag| ("tag", tag.as_str())));
335 fields.extend(self.claim_key.as_deref().map(|key| ("claim_key", key)));
336 fields.extend(
337 self.claim_value
338 .as_deref()
339 .map(|value| ("claim_value", value)),
340 );
341 fields.extend(
342 self.provenance
343 .locator
344 .as_deref()
345 .map(|locator| ("locator", locator)),
346 );
347 if let Some(record) = &self.prediction {
348 fields.push(("prediction family", record.family.as_str()));
349 fields.push(("prediction value", record.value.as_str()));
350 fields.extend(
351 record
352 .answer_space
353 .iter()
354 .map(|value| ("prediction answer space", value.as_str())),
355 );
356 }
357 match fields.into_iter().find(|(_, text)| contains_secret(text)) {
358 Some((field, _)) => Err(KernelError::Invalid(format!(
359 "trace {field} appears to contain a credential; memory never stores secrets. Record where the secret is kept (for example a vault path), not its value"
360 ))),
361 None => Ok(()),
362 }
363 }
364}
365
366pub fn parse_deadline(value: &str, now_ms: u64) -> Result<u64> {
369 let v = value.trim();
370 let invalid = || KernelError::Invalid(format!("unrecognized deadline: {value}"));
371 if let Some(rest) = v.strip_prefix('+') {
372 let (number, unit_ms) = if let Some(n) = rest.strip_suffix('h') {
373 (n, 3_600_000_u64)
374 } else if let Some(n) = rest.strip_suffix('d') {
375 (n, 86_400_000_u64)
376 } else {
377 return Err(invalid());
378 };
379 let n: u64 = number.parse().map_err(|_| invalid())?;
380 return Ok(now_ms.saturating_add(n.saturating_mul(unit_ms)));
381 }
382 if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
383 return v.parse().map_err(|_| invalid());
384 }
385 let v = v.strip_suffix('Z').unwrap_or(v);
386 let (date, time) = v.split_once('T').unwrap_or((v, "00:00:00"));
387 let number = |part: &str| -> Result<u64> {
388 if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) {
389 return Err(invalid());
390 }
391 part.parse::<u64>().map_err(|_| invalid())
392 };
393 let d: Vec<u64> = date.split('-').map(number).collect::<Result<_>>()?;
394 let t: Vec<u64> = time.split(':').map(number).collect::<Result<_>>()?;
395 if d.len() != 3 || !(1..=3).contains(&t.len()) {
396 return Err(invalid());
397 }
398 let (y, m, day) = (d[0], d[1], d[2]);
399 let (hh, mm, ss) = (t[0], *t.get(1).unwrap_or(&0), *t.get(2).unwrap_or(&0));
400 let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
401 let days_in_month = match m {
402 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
403 4 | 6 | 9 | 11 => 30,
404 2 if leap => 29,
405 2 => 28,
406 _ => return Err(invalid()),
407 };
408 if !(1970..=9999).contains(&y)
409 || day == 0
410 || day > days_in_month
411 || hh > 23
412 || mm > 59
413 || ss > 59
414 {
415 return Err(invalid());
416 }
417 let (y, m, day) = (y as i64, m as i64, day as i64);
419 let y = if m <= 2 { y - 1 } else { y };
420 let era = y / 400;
421 let yoe = y - era * 400;
422 let mp = (m + 9) % 12;
423 let doy = (153 * mp + 2) / 5 + day - 1;
424 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
425 let days = era * 146_097 + doe - 719_468;
426 let secs = days * 86_400 + (hh * 3_600 + mm * 60 + ss) as i64;
427 Ok(secs as u64 * 1_000)
428}
429
430pub fn now_ms() -> u64 {
431 SystemTime::now()
432 .duration_since(UNIX_EPOCH)
433 .unwrap_or_default()
434 .as_millis() as u64
435}