blob: 3e65d2c24e5a0c2f448028aa0e6bc29c20b8d1d0 [file]
//! The entry format and everything every other crate must agree on.
//!
//! No I/O lives here. This crate compiles to native, to wasm for the browser
//! reader, and to WASI for plugins.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
use time::OffsetDateTime;
pub mod erasure;
pub mod filter;
pub mod id;
pub mod manifest;
pub use filter::ReadFilter;
/// How sensitive an entry is. Ordered: public < internal < restricted.
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
)]
#[serde(rename_all = "lowercase")]
pub enum Label {
/// Anyone may read it.
#[default]
Public,
/// The domain's members may read it.
Internal,
/// Only a session whose handling allows it may read it.
Restricted,
}
impl fmt::Display for Label {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Label::Public => "public",
Label::Internal => "internal",
Label::Restricted => "restricted",
};
f.write_str(s)
}
}
impl std::str::FromStr for Label {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
match s {
"public" => Ok(Label::Public),
"internal" => Ok(Label::Internal),
"restricted" => Ok(Label::Restricted),
other => Err(Error::Invalid(format!("unknown label {other:?}"))),
}
}
}
/// How far an entry has been checked. Ordered: raw < curated < verified.
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
)]
#[serde(rename_all = "lowercase")]
pub enum TrustTier {
/// Written and not yet looked at. Searchable, with the tier visible.
#[default]
Raw,
/// A librarian has summarised, merged, or verified it.
Curated,
/// Passed the review gate; the approver was not the proposer.
Verified,
}
impl fmt::Display for TrustTier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
TrustTier::Raw => "raw",
TrustTier::Curated => "curated",
TrustTier::Verified => "verified",
})
}
}
impl std::str::FromStr for TrustTier {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
match s {
"raw" => Ok(TrustTier::Raw),
"curated" => Ok(TrustTier::Curated),
"verified" => Ok(TrustTier::Verified),
other => Err(Error::Invalid(format!("unknown trust tier {other:?}"))),
}
}
}
/// What kind of knowledge an entry holds. The kinds age differently.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EntryType {
/// Context, options, choice, rationale. Superseded, never expired.
Decision,
/// Decays fast; carries a review date. The default kind.
#[default]
Fact,
/// How things are done here; needs an owner.
Convention,
/// A strategy or skill: a convention about how to work, under stricter scrutiny.
Method,
/// What an agent wrote when it stopped, so the next one need not read its transcript.
Handoff,
/// A policy: a rule and its intent at a layer, L0 to L3, with a Rego module where it
/// is machine-checkable, or an exception to one. Enforced only once verified.
Policy,
}
impl fmt::Display for EntryType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
EntryType::Decision => "decision",
EntryType::Fact => "fact",
EntryType::Convention => "convention",
EntryType::Method => "method",
EntryType::Handoff => "handoff",
EntryType::Policy => "policy",
})
}
}
impl std::str::FromStr for EntryType {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
match s {
"decision" => Ok(EntryType::Decision),
"fact" => Ok(EntryType::Fact),
"convention" => Ok(EntryType::Convention),
"method" => Ok(EntryType::Method),
"handoff" => Ok(EntryType::Handoff),
"policy" => Ok(EntryType::Policy),
other => Err(Error::Invalid(format!("unknown entry type {other:?}"))),
}
}
}
/// The author's own confidence in an entry.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
/// Checked against a source.
High,
/// Believed, not checked.
Medium,
/// A guess worth recording.
Low,
}
/// An entry's place in its lifecycle. Absent means active.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
/// Demoted by curation; the body is still in the archive behind this tombstone.
Demoted,
/// Superseded by another entry, which names this one in its `supersedes`.
Superseded,
/// Erased: the body is gone everywhere, only this stub remains.
Erased,
}
/// The frontmatter every entry carries. Field names are the contract.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Frontmatter {
/// Stable, permanent, meaningless.
pub id: String,
/// What kind of knowledge this is.
#[serde(rename = "type")]
pub entry_type: EntryType,
/// A capability or a person, never a team name.
pub domain: String,
/// `agent:<name> (model: <id>)` or `human:<system>:<user-id>`.
pub author: String,
/// When the entry was written.
#[serde(with = "time::serde::rfc3339")]
pub created: OffsetDateTime,
/// Links or citations the entry was distilled from.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source: Vec<String>,
/// The author's confidence, if stated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<Confidence>,
/// How far the entry has been checked.
#[serde(default)]
pub trust_tier: TrustTier,
/// Decay clock, as a calendar date.
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "calendar_date"
)]
pub review_by: Option<time::Date>,
/// The entry this one replaces.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
/// The policy versions in force when it was written.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub policy_context: Vec<String>,
/// How sensitive the entry is; at least the label of what the author read.
#[serde(default)]
pub classification: Label,
/// Lifecycle state; absent means active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<Status>,
/// Where an imported or tapped entry came from, for dedup.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
/// One line, what the index serves before anyone opens the body.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// A title, when the body's first heading is not the right one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
/// `review_by` on disk: `2027-04-01`. An entry written before this was fixed holds the
/// date as `time`'s own shape, a year and a day of the year, and still reads.
mod calendar_date {
use serde::{de::Error as _, Deserialize, Deserializer, Serializer};
use time::{macros::format_description, Date};
#[derive(Deserialize)]
#[serde(untagged)]
enum Shape {
Text(String),
YearAndDay(i32, u16),
}
pub(crate) fn serialize<S: Serializer>(date: &Option<Date>, s: S) -> Result<S::Ok, S::Error> {
match date {
Some(d) => s.collect_str(d),
None => s.serialize_none(),
}
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Date>, D::Error> {
let parsed = match Option::<Shape>::deserialize(d)? {
None => return Ok(None),
Some(Shape::Text(s)) => Date::parse(&s, format_description!("[year]-[month]-[day]"))
.map_err(|e| D::Error::custom(format!("review_by {s:?}: {e}")))?,
Some(Shape::YearAndDay(year, day)) => Date::from_ordinal_date(year, day)
.map_err(|e| D::Error::custom(format!("review_by [{year}, {day}]: {e}")))?,
};
Ok(Some(parsed))
}
}
/// One entry: frontmatter plus a Markdown body.
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The frontmatter.
pub front: Frontmatter,
/// The Markdown body, without the frontmatter block.
pub body: String,
}
/// What can go wrong reading or building an entry.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The text does not start with a `---` fenced frontmatter block.
#[error("entry has no frontmatter block")]
NoFrontmatter,
/// The frontmatter is not valid YAML, or does not fit [`Frontmatter`].
#[error("frontmatter: {0}")]
Yaml(#[from] serde_norway::Error),
/// A value is well-formed but not allowed.
#[error("invalid: {0}")]
Invalid(String),
}
const FENCE: &str = "---";
impl Entry {
/// Parse `---\nyaml\n---\nbody`.
///
/// # Errors
///
/// [`Error::NoFrontmatter`] when the fences are missing, [`Error::Yaml`] when the
/// block does not fit [`Frontmatter`], and [`Error::Invalid`] when `id` or `domain`
/// is empty.
pub fn parse(text: &str) -> Result<Entry, Error> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut lines = text.split_inclusive('\n');
let first = lines.next().ok_or(Error::NoFrontmatter)?;
if first.trim_end() != FENCE {
return Err(Error::NoFrontmatter);
}
let mut yaml = String::new();
let mut body = String::new();
let mut in_front = true;
for line in lines {
if in_front {
if line.trim_end() == FENCE {
in_front = false;
} else {
yaml.push_str(line);
}
} else {
body.push_str(line);
}
}
if in_front {
return Err(Error::NoFrontmatter);
}
let front: Frontmatter = serde_norway::from_str(&yaml)?;
if front.id.is_empty() || front.domain.is_empty() {
return Err(Error::Invalid("id and domain are required".into()));
}
let body = body.strip_prefix('\n').unwrap_or(&body).to_string();
Ok(Entry { front, body })
}
/// Render to the on-disk form. `parse(render(e)) == e`.
///
/// # Panics
///
/// Only if [`Frontmatter`] stops being serialisable, which a test guards.
pub fn render(&self) -> String {
let yaml = serde_norway::to_string(&self.front).expect("frontmatter serialises");
let mut out = String::with_capacity(yaml.len() + self.body.len() + 16);
out.push_str(FENCE);
out.push('\n');
out.push_str(&yaml);
if !yaml.ends_with('\n') {
out.push('\n');
}
out.push_str(FENCE);
out.push('\n');
if !self.body.is_empty() {
out.push('\n');
out.push_str(&self.body);
if !self.body.ends_with('\n') {
out.push('\n');
}
}
out
}
/// The title the index shows: explicit, else the first heading, else the id.
#[must_use]
pub fn title(&self) -> String {
if let Some(t) = &self.front.title {
return t.clone();
}
self.body
.lines()
.find_map(|l| l.strip_prefix("# ").or_else(|| l.strip_prefix("## ")))
.map(|s| s.trim().to_string())
.unwrap_or_else(|| self.front.id.clone())
}
/// The one-line summary the index serves: explicit, else the first paragraph, clipped.
#[must_use]
pub fn summary(&self) -> String {
if let Some(s) = &self.front.summary {
return clip(s, 200);
}
let para: String = self
.body
.lines()
.map(str::trim)
.skip_while(|l| l.is_empty() || l.starts_with('#'))
.take_while(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
clip(&para, 200)
}
/// Whether the entry is live, as opposed to demoted, superseded, or erased.
#[must_use]
pub fn is_active(&self) -> bool {
self.front.status.is_none()
}
}
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let cut: String = s.chars().take(max - 1).collect();
format!("{}…", cut.trim_end())
}
/// Content digest, hex SHA-256. What manifests and stubs carry.
#[must_use]
pub fn digest(text: &str) -> String {
hex::encode(Sha256::digest(text.as_bytes()))
}
/// A rough token count for budgeting T0: whitespace-split words times 1.3.
#[must_use]
pub fn approx_tokens(text: &str) -> usize {
let words = text.split_whitespace().count();
words + words.div_ceil(3)
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{date, datetime};
fn sample() -> Entry {
Entry {
front: Frontmatter {
id: "mem-a-0847".into(),
entry_type: EntryType::Decision,
domain: "dom-a".into(),
author: "agent:worker-3 (model: test-1)".into(),
created: datetime!(2026-10-01 10:00 UTC),
source: vec!["https://example.org/thread/1".into()],
confidence: Some(Confidence::High),
trust_tier: TrustTier::Curated,
review_by: Some(date!(2027-04-01)),
supersedes: Some("mem-a-0312".into()),
policy_context: vec!["pol-L2-031".into()],
classification: Label::Internal,
status: None,
origin: None,
summary: None,
title: None,
},
body: "# Retry with backoff\n\nOperation R retries with exponential backoff.\nState lives in header H.\n\n## Options\n\nTwo were considered.\n".into(),
}
}
#[test]
fn round_trip() {
let e = sample();
let text = e.render();
let back = Entry::parse(&text).unwrap();
assert_eq!(e, back);
assert_eq!(text, back.render());
}
#[test]
fn review_by_is_written_as_a_date_and_read_in_either_shape() {
let text = sample().render();
assert!(text.contains("\nreview_by: 2027-04-01\n"), "{text}");
// what a binary from before wrote: the year and the day of the year
let written = text.replace("review_by: 2027-04-01", "review_by:\n- 2027\n- 91");
let older = Entry::parse(&written).unwrap();
assert_eq!(older.front.review_by, Some(date!(2027 - 04 - 01)));
assert_eq!(older.render(), text);
let by_hand = text.replace("review_by: 2027-04-01", "review_by: '2027-04-01'");
assert_eq!(Entry::parse(&by_hand).unwrap(), sample());
for bad in [
"2027-02-30",
"2027-04-01T00:00:00Z",
"[2027, 400]",
"tomorrow",
"[2027]",
] {
let t = text.replace("review_by: 2027-04-01", &format!("review_by: {bad}"));
assert!(Entry::parse(&t).is_err(), "{bad}");
}
}
#[test]
fn minimal_frontmatter_defaults() {
let text = "---\nid: mem-x-1\ntype: handoff\ndomain: dom-x\nauthor: human:local:me\ncreated: 2026-10-01T10:00:00Z\n---\n\nStopped at step 3.\n";
let e = Entry::parse(text).unwrap();
assert_eq!(e.front.trust_tier, TrustTier::Raw);
assert_eq!(e.front.classification, Label::Public);
assert!(e.front.source.is_empty());
assert_eq!(e.body, "Stopped at step 3.\n");
assert_eq!(e.title(), "mem-x-1");
assert_eq!(e.summary(), "Stopped at step 3.");
assert!(e.is_active());
}
#[test]
fn title_and_summary_from_body() {
let e = sample();
assert_eq!(e.title(), "Retry with backoff");
assert_eq!(
e.summary(),
"Operation R retries with exponential backoff. State lives in header H."
);
}
#[test]
fn explicit_title_and_summary_win() {
let mut e = sample();
e.front.title = Some("Given".into());
e.front.summary = Some("Said once.".into());
assert_eq!(e.title(), "Given");
assert_eq!(e.summary(), "Said once.");
}
#[test]
fn summary_is_clipped() {
let mut e = sample();
e.body = "x ".repeat(300);
let s = e.summary();
assert_eq!(s.chars().count(), 200);
assert!(s.ends_with('…'));
e.front.summary = Some("y ".repeat(300));
assert_eq!(
e.summary().chars().count(),
200,
"an explicit summary is served clipped too; the frontmatter keeps it whole"
);
}
#[test]
fn labels_and_tiers_order_and_parse() {
assert!(Label::Public < Label::Internal);
assert!(Label::Internal < Label::Restricted);
assert_eq!("restricted".parse::<Label>().unwrap(), Label::Restricted);
assert!("secret".parse::<Label>().is_err());
assert_eq!(Label::Internal.to_string(), "internal");
assert!(TrustTier::Raw < TrustTier::Verified);
assert_eq!(TrustTier::Curated.to_string(), "curated");
assert_eq!("method".parse::<EntryType>().unwrap(), EntryType::Method);
assert!("rumour".parse::<EntryType>().is_err());
assert_eq!(EntryType::Handoff.to_string(), "handoff");
}
#[test]
fn rejects_without_frontmatter() {
assert!(matches!(
Entry::parse("just text"),
Err(Error::NoFrontmatter)
));
assert!(matches!(
Entry::parse("---\nid: x\n"),
Err(Error::NoFrontmatter)
));
assert!(matches!(Entry::parse(""), Err(Error::NoFrontmatter)));
}
#[test]
fn rejects_empty_id_or_domain() {
let text = "---\nid: \"\"\ntype: fact\ndomain: dom-x\nauthor: a\ncreated: 2026-10-01T10:00:00Z\n---\n";
assert!(matches!(Entry::parse(text), Err(Error::Invalid(_))));
let bad_yaml = "---\nid: [\ntype: fact\n---\n";
assert!(matches!(Entry::parse(bad_yaml), Err(Error::Yaml(_))));
}
#[test]
fn status_round_trips_and_marks_inactive() {
let mut e = sample();
e.front.status = Some(Status::Demoted);
let back = Entry::parse(&e.render()).unwrap();
assert_eq!(back.front.status, Some(Status::Demoted));
assert!(!back.is_active());
}
#[test]
fn digest_is_stable() {
assert_eq!(digest("a"), digest("a"));
assert_ne!(digest("a"), digest("b"));
assert_eq!(digest("").len(), 64);
}
#[test]
fn approx_tokens_scales_words() {
assert_eq!(approx_tokens(""), 0);
assert_eq!(approx_tokens("one two three"), 4);
assert_eq!(approx_tokens(&"w ".repeat(300)), 400);
}
}