#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum EntryType {
Decision,
Rejected,
Assumption,
Intent,
Onboarding,
History,
OpenTension,
}
impl EntryType {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
Ok(match s {
"decision" => Self::Decision,
"rejected" => Self::Rejected,
"assumption" => Self::Assumption,
"intent" => Self::Intent,
"onboarding" => Self::Onboarding,
"history" => Self::History,
"open-tension" => Self::OpenTension,
other => {
return Err(format!(
"unknown entry type {other:?} (expected one of: decision, rejected, \
assumption, intent, onboarding, history, open-tension)"
))
}
})
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Decision => "decision",
Self::Rejected => "rejected",
Self::Assumption => "assumption",
Self::Intent => "intent",
Self::Onboarding => "onboarding",
Self::History => "history",
Self::OpenTension => "open-tension",
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct Entry {
pub(crate) id: String,
pub(crate) entry_type: EntryType,
pub(crate) subjects: Vec<String>,
pub(crate) supersedes: Option<String>,
pub(crate) foundational: bool,
pub(crate) redacted: bool,
pub(crate) sources: Vec<String>,
pub(crate) author: String,
pub(crate) ratified_by: String,
pub(crate) date: String,
pub(crate) correlation_id: String,
pub(crate) body: String,
}
impl Entry {
pub(crate) fn parse(id_from_filename: &str, raw: &str) -> Result<Entry, String> {
let (frontmatter, body) = split_frontmatter(raw)?;
let doc = frontmatter
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("invalid TOML frontmatter: {e}"))?;
let get_str = |k: &str| doc.get(k).and_then(|v| v.as_str()).map(str::to_string);
let require =
|k: &str| get_str(k).ok_or_else(|| format!("frontmatter missing required `{k}`"));
let id = require("id")?;
if id != id_from_filename {
return Err(format!(
"frontmatter id {id:?} does not match filename {id_from_filename:?}"
));
}
let entry_type = EntryType::parse(&require("type")?)?;
let supersedes = get_str("supersedes").filter(|s| !s.is_empty());
let foundational = doc
.get("foundational")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let redacted = doc
.get("redacted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let author = require("author")?;
let ratified_by = require("ratified_by")?;
let date = require("date")?;
validate_iso_date(&date)?;
let correlation_id = require("correlation_id")?;
if body.is_empty() {
return Err("entry body (the reasoning prose) is empty".to_string());
}
Ok(Entry {
id,
entry_type,
subjects: str_array(&doc, "subjects"),
supersedes,
foundational,
redacted,
sources: str_array(&doc, "sources"),
author,
ratified_by,
date,
correlation_id,
body,
})
}
pub(crate) fn to_file_string(&self) -> String {
use toml_edit::{value, Array, DocumentMut};
let mut subjects = Array::new();
for s in &self.subjects {
subjects.push(s.as_str());
}
let mut sources = Array::new();
for s in &self.sources {
sources.push(s.as_str());
}
let mut doc = DocumentMut::new();
doc["id"] = value(self.id.as_str());
doc["type"] = value(self.entry_type.as_str());
doc["subjects"] = value(subjects);
doc["supersedes"] = value(self.supersedes.as_deref().unwrap_or(""));
doc["foundational"] = value(self.foundational);
doc["redacted"] = value(self.redacted);
doc["sources"] = value(sources);
doc["author"] = value(self.author.as_str());
doc["ratified_by"] = value(self.ratified_by.as_str());
doc["date"] = value(self.date.as_str());
doc["correlation_id"] = value(self.correlation_id.as_str());
format!("+++\n{doc}+++\n\n{}\n", self.body)
}
}
pub(crate) fn short(id: &str) -> &str {
&id[id.len().saturating_sub(8)..]
}
fn split_frontmatter(raw: &str) -> Result<(String, String), String> {
let mut lines = raw.lines();
match lines.next() {
Some(first) if first.trim() == "+++" => {}
_ => return Err("entry must begin with a `+++` TOML frontmatter fence".to_string()),
}
let mut frontmatter = String::new();
let mut closed = false;
for line in lines.by_ref() {
if line.trim() == "+++" {
closed = true;
break;
}
frontmatter.push_str(line);
frontmatter.push('\n');
}
if !closed {
return Err("unterminated `+++` frontmatter fence".to_string());
}
let mut body = String::new();
for line in lines {
body.push_str(line);
body.push('\n');
}
Ok((frontmatter, body.trim().to_string()))
}
fn str_array(doc: &toml_edit::DocumentMut, key: &str) -> Vec<String> {
doc.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn validate_iso_date(d: &str) -> Result<(), String> {
let b = d.as_bytes();
let ok = b.len() == 10
&& b[4] == b'-'
&& b[7] == b'-'
&& d.char_indices().all(|(i, c)| match i {
4 | 7 => c == '-',
_ => c.is_ascii_digit(),
});
if ok {
Ok(())
} else {
Err(format!("date {d:?} is not ISO YYYY-MM-DD"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_raw(id: &str) -> String {
format!(
"+++\n\
id = \"{id}\"\n\
type = \"rejected\"\n\
subjects = [\"auth\", \"sessions\"]\n\
supersedes = \"\"\n\
foundational = false\n\
sources = [\"pr#41\"]\n\
author = \"ai:claude-code\"\n\
ratified_by = \"amir@team\"\n\
date = \"2026-06-02\"\n\
correlation_id = \"0190abcd\"\n\
+++\n\
\n\
We considered LISTEN/NOTIFY and rejected it because polling was simpler.\n"
)
}
#[test]
fn parses_a_valid_entry() {
let e = Entry::parse("entry-1", &valid_raw("entry-1")).expect("parses");
assert_eq!(e.id, "entry-1");
assert_eq!(e.entry_type, EntryType::Rejected);
assert_eq!(e.subjects, vec!["auth", "sessions"]);
assert_eq!(e.supersedes, None);
assert!(!e.foundational);
assert_eq!(e.sources, vec!["pr#41"]);
assert_eq!(e.date, "2026-06-02");
assert!(e.body.starts_with("We considered"));
}
#[test]
fn empty_supersedes_is_none() {
let e = Entry::parse("x", &valid_raw("x")).expect("parses");
assert_eq!(e.supersedes, None);
}
#[test]
fn nonempty_supersedes_is_kept() {
let raw = valid_raw("x").replace("supersedes = \"\"", "supersedes = \"older-1\"");
let e = Entry::parse("x", &raw).expect("parses");
assert_eq!(e.supersedes.as_deref(), Some("older-1"));
}
#[test]
fn filename_id_mismatch_is_rejected() {
let err = Entry::parse("WRONG", &valid_raw("entry-1")).unwrap_err();
assert!(err.contains("does not match filename"), "{err}");
}
#[test]
fn missing_required_field_is_rejected() {
let raw = valid_raw("x").replace("author = \"ai:claude-code\"\n", "");
let err = Entry::parse("x", &raw).unwrap_err();
assert!(err.contains("missing required `author`"), "{err}");
}
#[test]
fn unknown_type_is_rejected() {
let raw = valid_raw("x").replace("type = \"rejected\"", "type = \"musing\"");
let err = Entry::parse("x", &raw).unwrap_err();
assert!(err.contains("unknown entry type"), "{err}");
}
#[test]
fn bad_date_is_rejected() {
let raw = valid_raw("x").replace("date = \"2026-06-02\"", "date = \"June 2\"");
let err = Entry::parse("x", &raw).unwrap_err();
assert!(err.contains("ISO YYYY-MM-DD"), "{err}");
}
#[test]
fn empty_body_is_rejected() {
let raw = valid_raw("x");
let head = &raw[..raw.rfind("+++").unwrap() + 3];
let err = Entry::parse("x", &format!("{head}\n\n")).unwrap_err();
assert!(err.contains("body"), "{err}");
}
#[test]
fn missing_opening_fence_is_rejected() {
let err = Entry::parse("x", "id = \"x\"\n").unwrap_err();
assert!(err.contains("must begin with a `+++`"), "{err}");
}
#[test]
fn unterminated_fence_is_rejected() {
let err = Entry::parse("x", "+++\nid = \"x\"\n").unwrap_err();
assert!(err.contains("unterminated"), "{err}");
}
#[test]
fn short_handle_is_suffix() {
assert_eq!(short("01J9ZABCDEFGH"), "ABCDEFGH");
assert_eq!(short("tiny"), "tiny");
}
#[test]
fn serialize_round_trips_through_parse() {
let original = Entry::parse("entry-1", &valid_raw("entry-1")).expect("parse");
let serialized = original.to_file_string();
let reparsed = Entry::parse("entry-1", &serialized).expect("reparse");
assert_eq!(reparsed.id, original.id);
assert_eq!(reparsed.entry_type, original.entry_type);
assert_eq!(reparsed.subjects, original.subjects);
assert_eq!(reparsed.supersedes, original.supersedes);
assert_eq!(reparsed.foundational, original.foundational);
assert_eq!(reparsed.redacted, original.redacted);
assert_eq!(reparsed.sources, original.sources);
assert_eq!(reparsed.author, original.author);
assert_eq!(reparsed.ratified_by, original.ratified_by);
assert_eq!(reparsed.date, original.date);
assert_eq!(reparsed.correlation_id, original.correlation_id);
assert_eq!(reparsed.body, original.body);
}
#[test]
fn serialized_supersedes_round_trips_as_none() {
let e = Entry::parse("x", &valid_raw("x")).expect("parse");
let reparsed = Entry::parse("x", &e.to_file_string()).expect("reparse");
assert_eq!(reparsed.supersedes, None);
}
}