use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::entity::{
self, Artifact, Fileset, Inputs, Kind, LocalFs, MaterialiseRequest, ScaffoldCtx,
};
use crate::listing::{self, Format, ListArgs};
use crate::tomlfmt::toml_string;
#[cfg(test)]
use crate::tomlfmt::toml_array_inner;
const RECORD_STEM: &str = "record";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum RecordKind {
Assumption,
Decision,
Question,
Constraint,
}
pub(crate) const ASSUMPTION_KIND: Kind = Kind {
dir: ".doctrine/knowledge/assumption",
prefix: crate::kinds::ASM,
scaffold: |c| record_scaffold(RecordKind::Assumption, c),
};
pub(crate) const DECISION_KIND: Kind = Kind {
dir: ".doctrine/knowledge/decision",
prefix: crate::kinds::DEC,
scaffold: |c| record_scaffold(RecordKind::Decision, c),
};
pub(crate) const QUESTION_KIND: Kind = Kind {
dir: ".doctrine/knowledge/question",
prefix: crate::kinds::QUE,
scaffold: |c| record_scaffold(RecordKind::Question, c),
};
pub(crate) const CONSTRAINT_KIND: Kind = Kind {
dir: ".doctrine/knowledge/constraint",
prefix: crate::kinds::CON,
scaffold: |c| record_scaffold(RecordKind::Constraint, c),
};
impl RecordKind {
pub(crate) const fn kind(self) -> &'static Kind {
match self {
RecordKind::Assumption => &ASSUMPTION_KIND,
RecordKind::Decision => &DECISION_KIND,
RecordKind::Question => &QUESTION_KIND,
RecordKind::Constraint => &CONSTRAINT_KIND,
}
}
pub(crate) const fn prefix(self) -> &'static str {
self.kind().prefix
}
pub(crate) const fn as_str(self) -> &'static str {
match self {
RecordKind::Assumption => "assumption",
RecordKind::Decision => "decision",
RecordKind::Question => "question",
RecordKind::Constraint => "constraint",
}
}
pub(crate) fn canonical_id(self, id: u32) -> String {
format!("{}-{id:03}", self.prefix())
}
pub(crate) fn from_prefix(prefix: &str) -> Option<Self> {
RecordKind::ALL.into_iter().find(|k| k.prefix() == prefix)
}
#[cfg(test)]
pub(crate) fn default_status(self) -> &'static str {
statuses(self).first().copied().unwrap_or_default()
}
pub(crate) fn is_terminal(self, status: &str) -> bool {
terminal(self).contains(&status)
}
pub(crate) const ALL: [RecordKind; 4] = [
RecordKind::Assumption,
RecordKind::Decision,
RecordKind::Question,
RecordKind::Constraint,
];
}
pub(crate) const ASSUMPTION_STATUSES: &[&str] =
&["held", "testing", "validated", "invalidated", "obsolete"];
pub(crate) const DECISION_STATUSES: &[&str] = &["proposed", "accepted", "rejected", "superseded"];
pub(crate) const QUESTION_STATUSES: &[&str] = &["open", "answered", "obsolete"];
pub(crate) const CONSTRAINT_STATUSES: &[&str] = &["active", "waived", "superseded", "retired"];
const ASSUMPTION_HIDDEN: &[&str] = &["validated", "invalidated", "obsolete"];
const DECISION_HIDDEN: &[&str] = &["rejected", "superseded"];
const QUESTION_HIDDEN: &[&str] = &["answered", "obsolete"];
const CONSTRAINT_HIDDEN: &[&str] = &["waived", "superseded", "retired"];
const ASSUMPTION_TERMINAL: &[&str] = &["validated", "invalidated", "obsolete"];
const DECISION_TERMINAL: &[&str] = &["accepted", "rejected", "superseded"];
const QUESTION_TERMINAL: &[&str] = &["answered", "obsolete"];
const CONSTRAINT_TERMINAL: &[&str] = &["waived", "superseded", "retired"];
pub(crate) fn statuses(k: RecordKind) -> &'static [&'static str] {
match k {
RecordKind::Assumption => ASSUMPTION_STATUSES,
RecordKind::Decision => DECISION_STATUSES,
RecordKind::Question => QUESTION_STATUSES,
RecordKind::Constraint => CONSTRAINT_STATUSES,
}
}
pub(crate) fn is_hidden(k: RecordKind, status: &str) -> bool {
hidden(k).contains(&status)
}
const fn hidden(k: RecordKind) -> &'static [&'static str] {
match k {
RecordKind::Assumption => ASSUMPTION_HIDDEN,
RecordKind::Decision => DECISION_HIDDEN,
RecordKind::Question => QUESTION_HIDDEN,
RecordKind::Constraint => CONSTRAINT_HIDDEN,
}
}
const fn terminal(k: RecordKind) -> &'static [&'static str] {
match k {
RecordKind::Assumption => ASSUMPTION_TERMINAL,
RecordKind::Decision => DECISION_TERMINAL,
RecordKind::Question => QUESTION_TERMINAL,
RecordKind::Constraint => CONSTRAINT_TERMINAL,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Confidence {
Low,
Medium,
High,
}
impl Confidence {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Confidence::Low => "low",
Confidence::Medium => "medium",
Confidence::High => "high",
}
}
#[cfg(test)]
pub(crate) const KNOWN: &'static [&'static str] = &["low", "medium", "high"];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Basis {
Observation,
PriorArt,
DesignInference,
ExternalSource,
OperatorJudgement,
}
impl Basis {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Basis::Observation => "observation",
Basis::PriorArt => "prior-art",
Basis::DesignInference => "design-inference",
Basis::ExternalSource => "external-source",
Basis::OperatorJudgement => "operator-judgement",
}
}
#[cfg(test)]
pub(crate) const KNOWN: &'static [&'static str] = &[
"observation",
"prior-art",
"design-inference",
"external-source",
"operator-judgement",
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ConstraintSource {
Canon,
Adr,
External,
Technical,
Legal,
Compatibility,
Operator,
}
impl ConstraintSource {
pub(crate) const fn as_str(self) -> &'static str {
match self {
ConstraintSource::Canon => "canon",
ConstraintSource::Adr => "adr",
ConstraintSource::External => "external",
ConstraintSource::Technical => "technical",
ConstraintSource::Legal => "legal",
ConstraintSource::Compatibility => "compatibility",
ConstraintSource::Operator => "operator",
}
}
#[cfg(test)]
pub(crate) const KNOWN: &'static [&'static str] = &[
"canon",
"adr",
"external",
"technical",
"legal",
"compatibility",
"operator",
];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KnowledgeRecord {
id: u32,
slug: String,
title: String,
record_kind: RecordKind,
status: String,
created: String,
updated: String,
tags: Vec<String>,
facet: RecordFacet,
evidence: Evidence,
tier1: Vec<crate::relation::RelationEdge>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RecordFacet {
Assumption(AssumptionFacet),
Decision(DecisionFacet),
Question(QuestionFacet),
Constraint(ConstraintFacet),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct AssumptionFacet {
claim: Option<String>,
confidence: Option<Confidence>,
basis: Option<Basis>,
validation_plan: Option<String>,
validated_by: Option<String>,
validated_on: Option<String>,
invalidated_by: Option<String>,
invalidated_on: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DecisionFacet {
context: Option<String>,
choice: Option<String>,
alternatives: Vec<String>,
rationale: Option<String>,
consequences: Vec<String>,
decided_by: Option<String>,
decided_on: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct QuestionFacet {
question: Option<String>,
why_matters: Option<String>,
answer: Option<String>,
answered_by: Option<String>,
answered_on: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ConstraintFacet {
statement: Option<String>,
source: Option<ConstraintSource>,
applies_to: Vec<String>,
waiver_reason: Option<String>,
waived_by: Option<String>,
waived_on: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Evidence {
supports: Vec<String>,
contradicts: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawRecordToml {
id: u32,
slug: String,
title: String,
record_kind: RecordKind,
status: String,
created: String,
updated: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
facet: RawFacet,
#[serde(default)]
evidence: RawEvidence,
}
#[derive(Debug, Default, Deserialize)]
struct RawFacet {
#[serde(default)]
claim: String,
#[serde(default)]
confidence: String,
#[serde(default)]
basis: String,
#[serde(default)]
validation_plan: String,
#[serde(default)]
validated_by: String,
#[serde(default)]
validated_on: String,
#[serde(default)]
invalidated_by: String,
#[serde(default)]
invalidated_on: String,
#[serde(default)]
context: String,
#[serde(default)]
choice: String,
#[serde(default)]
alternatives: Vec<String>,
#[serde(default)]
rationale: String,
#[serde(default)]
consequences: Vec<String>,
#[serde(default)]
decided_by: String,
#[serde(default)]
decided_on: String,
#[serde(default)]
question: String,
#[serde(default)]
why_matters: String,
#[serde(default)]
answer: String,
#[serde(default)]
answered_by: String,
#[serde(default)]
answered_on: String,
#[serde(default)]
statement: String,
#[serde(default)]
source: String,
#[serde(default)]
applies_to: Vec<String>,
#[serde(default)]
waiver_reason: String,
#[serde(default)]
waived_by: String,
#[serde(default)]
waived_on: String,
}
#[derive(Debug, Default, Deserialize)]
struct RawEvidence {
#[serde(default)]
supports: Vec<String>,
#[serde(default)]
contradicts: Vec<String>,
#[serde(default)]
notes: Vec<String>,
}
fn parse_enum<T: serde::de::DeserializeOwned>(token: &str, what: &str) -> anyhow::Result<T> {
use serde::de::IntoDeserializer;
let de: serde::de::value::StrDeserializer<'_, serde::de::value::Error> =
token.into_deserializer();
T::deserialize(de).map_err(|e| anyhow::anyhow!("invalid {what} `{token}`: {e}"))
}
fn optional_enum<T: serde::de::DeserializeOwned>(
token: &str,
what: &str,
) -> anyhow::Result<Option<T>> {
if token.is_empty() {
Ok(None)
} else {
parse_enum(token, what).map(Some)
}
}
fn optional_text(text: String) -> Option<String> {
if text.is_empty() { None } else { Some(text) }
}
fn validate(raw: RawRecordToml) -> anyhow::Result<KnowledgeRecord> {
let facet = validate_facet(raw.record_kind, raw.facet)?;
let evidence = Evidence {
supports: raw.evidence.supports,
contradicts: raw.evidence.contradicts,
notes: raw.evidence.notes,
};
Ok(KnowledgeRecord {
id: raw.id,
slug: raw.slug,
title: raw.title,
record_kind: raw.record_kind,
status: raw.status,
created: raw.created,
updated: raw.updated,
tags: raw.tags,
facet,
evidence,
tier1: Vec::new(),
})
}
fn validate_facet(kind: RecordKind, raw: RawFacet) -> anyhow::Result<RecordFacet> {
Ok(match kind {
RecordKind::Assumption => RecordFacet::Assumption(AssumptionFacet {
claim: optional_text(raw.claim),
confidence: optional_enum(&raw.confidence, "confidence")?,
basis: optional_enum(&raw.basis, "basis")?,
validation_plan: optional_text(raw.validation_plan),
validated_by: optional_text(raw.validated_by),
validated_on: optional_text(raw.validated_on),
invalidated_by: optional_text(raw.invalidated_by),
invalidated_on: optional_text(raw.invalidated_on),
}),
RecordKind::Decision => RecordFacet::Decision(DecisionFacet {
context: optional_text(raw.context),
choice: optional_text(raw.choice),
alternatives: raw.alternatives,
rationale: optional_text(raw.rationale),
consequences: raw.consequences,
decided_by: optional_text(raw.decided_by),
decided_on: optional_text(raw.decided_on),
}),
RecordKind::Question => RecordFacet::Question(QuestionFacet {
question: optional_text(raw.question),
why_matters: optional_text(raw.why_matters),
answer: optional_text(raw.answer),
answered_by: optional_text(raw.answered_by),
answered_on: optional_text(raw.answered_on),
}),
RecordKind::Constraint => RecordFacet::Constraint(ConstraintFacet {
statement: optional_text(raw.statement),
source: optional_enum(&raw.source, "source")?,
applies_to: raw.applies_to,
waiver_reason: optional_text(raw.waiver_reason),
waived_by: optional_text(raw.waived_by),
waived_on: optional_text(raw.waived_on),
}),
})
}
#[cfg(test)]
fn render_record_toml(record: &KnowledgeRecord) -> String {
[
String::from("schema = \"doctrine.knowledge\"\nversion = 1\n\n"),
format!("id = {}\n", record.id),
format!("slug = {}\n", toml_string(&record.slug)),
format!("title = {}\n", toml_string(&record.title)),
format!("record_kind = \"{}\"\n", record.record_kind.as_str()),
format!("status = {}\n", toml_string(&record.status)),
format!("created = {}\n", toml_string(&record.created)),
format!("updated = {}\n", toml_string(&record.updated)),
format!("tags = [{}]\n", toml_array_inner(&record.tags)),
render_facet(&record.facet),
render_evidence(&record.evidence),
]
.concat()
}
#[cfg(test)]
fn opt_text_line(key: &str, value: Option<&str>) -> String {
format!("{key} = {}\n", toml_string(value.unwrap_or("")))
}
#[cfg(test)]
fn list_line(key: &str, xs: &[String]) -> String {
format!("{key} = [{}]\n", toml_array_inner(xs))
}
#[cfg(test)]
fn render_facet(facet: &RecordFacet) -> String {
let mut out = String::from("\n[facet]\n");
match facet {
RecordFacet::Assumption(f) => {
out.push_str(&opt_text_line("claim", f.claim.as_deref()));
out.push_str(&opt_text_line(
"confidence",
f.confidence.map(Confidence::as_str),
));
out.push_str(&opt_text_line("basis", f.basis.map(Basis::as_str)));
out.push_str(&opt_text_line(
"validation_plan",
f.validation_plan.as_deref(),
));
out.push_str(&opt_text_line("validated_by", f.validated_by.as_deref()));
out.push_str(&opt_text_line("validated_on", f.validated_on.as_deref()));
out.push_str(&opt_text_line(
"invalidated_by",
f.invalidated_by.as_deref(),
));
out.push_str(&opt_text_line(
"invalidated_on",
f.invalidated_on.as_deref(),
));
}
RecordFacet::Decision(f) => {
out.push_str(&opt_text_line("context", f.context.as_deref()));
out.push_str(&opt_text_line("choice", f.choice.as_deref()));
out.push_str(&list_line("alternatives", &f.alternatives));
out.push_str(&opt_text_line("rationale", f.rationale.as_deref()));
out.push_str(&list_line("consequences", &f.consequences));
out.push_str(&opt_text_line("decided_by", f.decided_by.as_deref()));
out.push_str(&opt_text_line("decided_on", f.decided_on.as_deref()));
}
RecordFacet::Question(f) => {
out.push_str(&opt_text_line("question", f.question.as_deref()));
out.push_str(&opt_text_line("why_matters", f.why_matters.as_deref()));
out.push_str(&opt_text_line("answer", f.answer.as_deref()));
out.push_str(&opt_text_line("answered_by", f.answered_by.as_deref()));
out.push_str(&opt_text_line("answered_on", f.answered_on.as_deref()));
}
RecordFacet::Constraint(f) => {
out.push_str(&opt_text_line("statement", f.statement.as_deref()));
out.push_str(&opt_text_line(
"source",
f.source.map(ConstraintSource::as_str),
));
out.push_str(&list_line("applies_to", &f.applies_to));
out.push_str(&opt_text_line("waiver_reason", f.waiver_reason.as_deref()));
out.push_str(&opt_text_line("waived_by", f.waived_by.as_deref()));
out.push_str(&opt_text_line("waived_on", f.waived_on.as_deref()));
}
}
out
}
#[cfg(test)]
fn render_evidence(e: &Evidence) -> String {
[
String::from("\n[evidence]\n"),
list_line("supports", &e.supports),
list_line("contradicts", &e.contradicts),
list_line("notes", &e.notes),
]
.concat()
}
fn render_record_toml_seed(
kind: RecordKind,
id: u32,
slug: &str,
title: &str,
date: &str,
) -> anyhow::Result<String> {
let template = match kind {
RecordKind::Assumption => "templates/knowledge-assumption.toml",
RecordKind::Decision => "templates/knowledge-decision.toml",
RecordKind::Question => "templates/knowledge-question.toml",
RecordKind::Constraint => "templates/knowledge-constraint.toml",
};
Ok(crate::install::asset_text(template)?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{date}}", date))
}
fn render_record_md(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/knowledge.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn record_scaffold(kind: RecordKind, ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let id = ctx.id;
let name = format!("{id:03}");
Ok(vec![
Artifact::File {
rel_path: PathBuf::from(format!("{name}/{RECORD_STEM}-{name}.toml")),
body: render_record_toml_seed(kind, id, ctx.slug, ctx.title, ctx.date)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/{RECORD_STEM}-{name}.md")),
body: render_record_md(ctx.canonical, ctx.title)?,
},
Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{}", ctx.slug)),
target: name,
},
])
}
fn resolve_ref(reference: &str) -> anyhow::Result<(RecordKind, u32)> {
let (prefix, tail) = reference.rsplit_once('-').with_context(|| {
format!("`{reference}` is not a canonical record ref (expected e.g. ASM-007)")
})?;
let kind = RecordKind::from_prefix(&prefix.to_uppercase()).with_context(|| {
format!("unknown record prefix `{prefix}` in `{reference}` (expected ASM/DEC/QUE/CON)")
})?;
let id: u32 = tail
.parse()
.with_context(|| format!("`{tail}` is not a numeric id in `{reference}`"))?;
Ok((kind, id))
}
fn union_statuses() -> Vec<&'static str> {
let mut union: Vec<&'static str> = Vec::new();
for kind in RecordKind::ALL {
for &status in statuses(kind) {
if !union.contains(&status) {
union.push(status);
}
}
}
union
}
fn read_record(root: &Path, kind: RecordKind, id: u32) -> anyhow::Result<KnowledgeRecord> {
let name = format!("{id:03}");
let path = root
.join(kind.kind().dir)
.join(&name)
.join(format!("{RECORD_STEM}-{name}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("record not found at {}", path.display()))?;
let raw: RawRecordToml =
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))?;
let mut record = validate(raw)?;
record.tier1 = crate::relation::tier1_edges(kind.kind(), &text)?;
Ok(record)
}
pub(crate) fn relation_edges(
root: &Path,
kind: RecordKind,
id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
let record = read_record(root, kind, id)?;
Ok(record.tier1)
}
fn read_kind(root: &Path, kind: RecordKind) -> anyhow::Result<Vec<KnowledgeRecord>> {
let tree = root.join(kind.kind().dir);
let mut records = Vec::new();
for id in entity::scan_ids(&tree)? {
records.push(read_record(root, kind, id)?);
}
Ok(records)
}
fn read_all(root: &Path) -> anyhow::Result<Vec<KnowledgeRecord>> {
let mut records = Vec::new();
for kind in RecordKind::ALL {
records.extend(read_kind(root, kind)?);
}
Ok(records)
}
pub(crate) fn run_new(
path: Option<PathBuf>,
record_kind: RecordKind,
title: Option<String>,
slug: Option<String>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let title = crate::input::resolve_title(title)?;
let slug = crate::input::resolve_slug(&title, slug)?;
let date = crate::clock::today();
let trunk_ids = crate::git::trunk_entity_ids(&root, record_kind.kind().dir)?;
let out = entity::materialise(
record_kind.kind(),
&LocalFs,
&root,
&MaterialiseRequest::Fresh,
&Inputs {
slug: &slug,
title: &title,
date: &date,
},
&trunk_ids,
)?;
let id = out
.eid
.numeric_id()
.context("knowledge kind must yield a numeric id")?;
writeln!(
io::stdout(),
"Created {}: {}",
record_kind.canonical_id(id),
out.dir.display()
)?;
Ok(())
}
fn format_show(record: &KnowledgeRecord) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!(
"{} — {}\n",
record.record_kind.canonical_id(record.id),
record.title
));
parts.push(format!(
"{} · {} · {}\n",
record.slug,
record.record_kind.as_str(),
record.status,
));
parts.push(format!(
"created {} · updated {}\n",
record.created, record.updated
));
if !record.tags.is_empty() {
parts.push(format!("tags: {}\n", record.tags.join(", ")));
}
parts.push(format_facet(&record.facet));
parts.push(format_evidence(&record.evidence));
for label in [
crate::relation::RelationLabel::Shapes,
crate::relation::RelationLabel::Spawns,
crate::relation::RelationLabel::GovernedBy,
] {
let targets = crate::relation::targets_for(&record.tier1, label);
if !targets.is_empty() {
let targets_str = targets.join(", ");
parts.push(format!("{}: [{}]\n", label.name(), targets_str));
}
}
parts.concat()
}
fn show_opt_line(key: &str, value: Option<&str>) -> String {
match value {
Some(v) => format!(" {key}: {v}\n"),
None => String::new(),
}
}
fn show_list_line(key: &str, xs: &[String]) -> String {
if xs.is_empty() {
String::new()
} else {
format!(" {key}: {}\n", xs.join(", "))
}
}
fn format_facet(facet: &RecordFacet) -> String {
let body = match facet {
RecordFacet::Assumption(f) => [
show_opt_line("claim", f.claim.as_deref()),
show_opt_line("confidence", f.confidence.map(Confidence::as_str)),
show_opt_line("basis", f.basis.map(Basis::as_str)),
show_opt_line("validation_plan", f.validation_plan.as_deref()),
show_opt_line("validated_by", f.validated_by.as_deref()),
show_opt_line("validated_on", f.validated_on.as_deref()),
show_opt_line("invalidated_by", f.invalidated_by.as_deref()),
show_opt_line("invalidated_on", f.invalidated_on.as_deref()),
]
.concat(),
RecordFacet::Decision(f) => [
show_opt_line("context", f.context.as_deref()),
show_opt_line("choice", f.choice.as_deref()),
show_list_line("alternatives", &f.alternatives),
show_opt_line("rationale", f.rationale.as_deref()),
show_list_line("consequences", &f.consequences),
show_opt_line("decided_by", f.decided_by.as_deref()),
show_opt_line("decided_on", f.decided_on.as_deref()),
]
.concat(),
RecordFacet::Question(f) => [
show_opt_line("question", f.question.as_deref()),
show_opt_line("why_matters", f.why_matters.as_deref()),
show_opt_line("answer", f.answer.as_deref()),
show_opt_line("answered_by", f.answered_by.as_deref()),
show_opt_line("answered_on", f.answered_on.as_deref()),
]
.concat(),
RecordFacet::Constraint(f) => [
show_opt_line("statement", f.statement.as_deref()),
show_opt_line("source", f.source.map(ConstraintSource::as_str)),
show_list_line("applies_to", &f.applies_to),
show_opt_line("waiver_reason", f.waiver_reason.as_deref()),
show_opt_line("waived_by", f.waived_by.as_deref()),
show_opt_line("waived_on", f.waived_on.as_deref()),
]
.concat(),
};
if body.is_empty() {
String::new()
} else {
format!("\n[facet]\n{body}")
}
}
fn format_evidence(e: &Evidence) -> String {
let body = [
show_list_line("supports", &e.supports),
show_list_line("contradicts", &e.contradicts),
show_list_line("notes", &e.notes),
]
.concat();
if body.is_empty() {
String::new()
} else {
format!("\n[evidence]\n{body}")
}
}
fn show_json(record: &KnowledgeRecord) -> anyhow::Result<String> {
let value = serde_json::json!({
"kind": "knowledge",
"knowledge": {
"id": record.record_kind.canonical_id(record.id),
"record_kind": record.record_kind.as_str(),
"slug": record.slug,
"title": record.title,
"status": record.status,
"created": record.created,
"updated": record.updated,
"tags": record.tags,
"facet": facet_json(&record.facet),
"evidence": {
"supports": record.evidence.supports,
"contradicts": record.evidence.contradicts,
"notes": record.evidence.notes,
},
"relationships": {
"shapes": crate::relation::targets_for(&record.tier1, crate::relation::RelationLabel::Shapes),
"spawns": crate::relation::targets_for(&record.tier1, crate::relation::RelationLabel::Spawns),
"governed_by": crate::relation::targets_for(&record.tier1, crate::relation::RelationLabel::GovernedBy),
},
},
});
serde_json::to_string_pretty(&value).context("failed to serialize knowledge show JSON")
}
fn facet_json(facet: &RecordFacet) -> serde_json::Value {
match facet {
RecordFacet::Assumption(f) => serde_json::json!({
"claim": f.claim,
"confidence": f.confidence.map(Confidence::as_str),
"basis": f.basis.map(Basis::as_str),
"validation_plan": f.validation_plan,
"validated_by": f.validated_by,
"validated_on": f.validated_on,
"invalidated_by": f.invalidated_by,
"invalidated_on": f.invalidated_on,
}),
RecordFacet::Decision(f) => serde_json::json!({
"context": f.context,
"choice": f.choice,
"alternatives": f.alternatives,
"rationale": f.rationale,
"consequences": f.consequences,
"decided_by": f.decided_by,
"decided_on": f.decided_on,
}),
RecordFacet::Question(f) => serde_json::json!({
"question": f.question,
"why_matters": f.why_matters,
"answer": f.answer,
"answered_by": f.answered_by,
"answered_on": f.answered_on,
}),
RecordFacet::Constraint(f) => serde_json::json!({
"statement": f.statement,
"source": f.source.map(ConstraintSource::as_str),
"applies_to": f.applies_to,
"waiver_reason": f.waiver_reason,
"waived_by": f.waived_by,
"waived_on": f.waived_on,
}),
}
}
pub(crate) fn run_show(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let (kind, id) = resolve_ref(reference)?;
let record = read_record(&root, kind, id)?;
let out = match format {
Format::Table => format_show(&record),
Format::Json => show_json(&record)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
#[derive(Debug, Serialize)]
struct RecordRow {
id: String,
record_kind: &'static str,
status: String,
slug: String,
title: String,
}
const KN_COLUMNS: [listing::Column<KnowledgeRecord>; 5] = [
listing::Column {
name: "id",
header: "id",
cell: |r| r.record_kind.canonical_id(r.id),
paint: listing::ColumnPaint::Fixed(owo_colors::DynColors::Ansi(
owo_colors::AnsiColors::Cyan,
)),
},
listing::Column {
name: "kind",
header: "kind",
cell: |r| r.record_kind.as_str().to_string(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: listing::ColumnPaint::ByValue(|r| listing::status_hue(&r.status)),
},
listing::Column {
name: "slug",
header: "slug",
cell: |r| r.slug.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "title",
header: "title",
cell: |r| r.title.clone(),
paint: listing::ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
},
];
const KN_DEFAULT: &[&str] = &["id", "kind", "status", "title"];
fn validate_statuses(given: &[String]) -> anyhow::Result<()> {
listing::validate_statuses(given, &union_statuses())
}
fn key(r: &KnowledgeRecord) -> listing::FilterFields {
listing::FilterFields {
canonical: r.record_kind.canonical_id(r.id),
slug: r.slug.clone(),
title: r.title.clone(),
status: r.status.clone(),
tags: r.tags.clone(),
}
}
fn json_rows(records: &[KnowledgeRecord]) -> Vec<RecordRow> {
records
.iter()
.map(|r| RecordRow {
id: r.record_kind.canonical_id(r.id),
record_kind: r.record_kind.as_str(),
status: r.status.clone(),
slug: r.slug.clone(),
title: r.title.clone(),
})
.collect()
}
fn list_rows(root: &Path, mut args: ListArgs) -> anyhow::Result<String> {
validate_statuses(&args.status)?;
let render = args.render;
let columns = args.columns.take();
let reveal_hidden = args.all || !args.status.is_empty();
let (filter, format) = listing::build(args)?;
let corpus = read_all(root)?;
let visible: Vec<KnowledgeRecord> = corpus
.into_iter()
.filter(|r| reveal_hidden || !is_hidden(r.record_kind, &r.status))
.collect();
let mut records = listing::retain(visible, &filter, |_| false, key);
records.sort_by_key(|r| (kind_ordinal(r.record_kind), r.id));
match format {
Format::Table => {
let sel = listing::select_columns(&KN_COLUMNS, KN_DEFAULT, columns.as_deref())?;
Ok(listing::render_columns(&records, &sel, render))
}
Format::Json => listing::json_envelope("knowledge", &json_rows(&records)),
}
}
fn kind_ordinal(kind: RecordKind) -> usize {
RecordKind::ALL
.iter()
.position(|&k| k == kind)
.unwrap_or(usize::MAX)
}
pub(crate) fn run_list(path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let out = list_rows(&root, args)?;
write!(io::stdout(), "{out}")?;
Ok(())
}
pub(crate) fn run_status(
path: Option<PathBuf>,
reference: &str,
state: &str,
color: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let (kind, id) = resolve_ref(reference)?;
let vocab = statuses(kind);
if !vocab.contains(&state) {
anyhow::bail!(
"`{state}` is not a {} status (known: {})",
kind.as_str(),
vocab.join(", ")
);
}
let today = crate::clock::today();
let name = format!("{id:03}");
let record_path = root
.join(kind.kind().dir)
.join(&name)
.join(format!("{RECORD_STEM}-{name}.toml"));
let hint = format!(
"malformed record {name}: missing seeded `status`/`updated` \
— restore the missing keys and retry; the file is left untouched"
);
crate::dep_seq::set_authored_status(
&record_path,
&[("status", state), ("updated", &today)],
&hint,
)?;
writeln!(
io::stdout(),
"{}: {}",
kind.canonical_id(id),
crate::listing::status_colored(state, color)
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::Meta;
use std::collections::BTreeSet;
use std::path::Path;
fn ctx_for(kind: RecordKind) -> ScaffoldCtx<'static> {
let canonical: &'static str = match kind {
RecordKind::Assumption => "ASM-003",
RecordKind::Decision => "DEC-003",
RecordKind::Question => "QUE-003",
RecordKind::Constraint => "CON-003",
};
ScaffoldCtx {
id: 3,
canonical,
slug: "token-expiry",
title: "Token expiry",
date: "2026-06-08",
}
}
#[test]
fn record_kind_from_prefix_round_trips_each_kind() {
for kind in RecordKind::ALL {
assert_eq!(RecordKind::from_prefix(kind.prefix()), Some(kind));
}
assert_eq!(RecordKind::from_prefix("REQ"), None);
let prefixes: BTreeSet<&str> = RecordKind::ALL.iter().map(|k| k.prefix()).collect();
assert_eq!(prefixes.len(), 4, "the four prefixes are distinct");
}
#[test]
fn canonical_id_uses_the_kind_prefix() {
assert_eq!(RecordKind::Assumption.canonical_id(7), "ASM-007");
assert_eq!(RecordKind::Decision.canonical_id(12), "DEC-012");
}
#[test]
fn default_status_is_the_first_vocab_element_per_kind() {
assert_eq!(RecordKind::Assumption.default_status(), "held");
assert_eq!(RecordKind::Decision.default_status(), "proposed");
assert_eq!(RecordKind::Question.default_status(), "open");
assert_eq!(RecordKind::Constraint.default_status(), "active");
for kind in RecordKind::ALL {
assert_eq!(Some(kind.default_status()), statuses(kind).first().copied());
}
}
#[test]
fn status_vocabularies_are_the_expected_known_sets() {
assert_eq!(
statuses(RecordKind::Assumption),
["held", "testing", "validated", "invalidated", "obsolete"]
);
assert_eq!(
statuses(RecordKind::Decision),
["proposed", "accepted", "rejected", "superseded"]
);
assert_eq!(
statuses(RecordKind::Question),
["open", "answered", "obsolete"]
);
assert_eq!(
statuses(RecordKind::Constraint),
["active", "waived", "superseded", "retired"]
);
}
#[test]
fn hide_set_is_a_subset_of_the_vocab_and_excludes_the_seed() {
for kind in RecordKind::ALL {
let vocab: BTreeSet<&str> = statuses(kind).iter().copied().collect();
for h in hidden(kind) {
assert!(vocab.contains(h), "{kind:?}: hidden `{h}` is in-vocab");
assert!(
!is_hidden(kind, kind.default_status()),
"{kind:?}: the seed is never hidden"
);
}
}
assert!(!is_hidden(RecordKind::Decision, "accepted"));
assert!(is_hidden(RecordKind::Decision, "superseded"));
}
#[test]
fn is_terminal_returns_correct_per_kind() {
assert!(!RecordKind::Assumption.is_terminal("held"));
assert!(!RecordKind::Assumption.is_terminal("testing"));
assert!(RecordKind::Assumption.is_terminal("validated"));
assert!(RecordKind::Assumption.is_terminal("invalidated"));
assert!(RecordKind::Assumption.is_terminal("obsolete"));
assert!(!RecordKind::Decision.is_terminal("proposed"));
assert!(RecordKind::Decision.is_terminal("accepted"));
assert!(RecordKind::Decision.is_terminal("rejected"));
assert!(RecordKind::Decision.is_terminal("superseded"));
assert!(!RecordKind::Question.is_terminal("open"));
assert!(RecordKind::Question.is_terminal("answered"));
assert!(RecordKind::Question.is_terminal("obsolete"));
assert!(!RecordKind::Constraint.is_terminal("active"));
assert!(RecordKind::Constraint.is_terminal("waived"));
assert!(RecordKind::Constraint.is_terminal("superseded"));
assert!(RecordKind::Constraint.is_terminal("retired"));
}
#[test]
fn terminal_set_is_subset_of_the_vocab_and_excludes_the_seed() {
for kind in RecordKind::ALL {
let vocab: BTreeSet<&str> = statuses(kind).iter().copied().collect();
for t in terminal(kind) {
assert!(vocab.contains(t), "{kind:?}: terminal `{t}` is in-vocab");
}
assert!(
!kind.is_terminal(kind.default_status()),
"{kind:?}: the seed is never terminal"
);
}
assert!(RecordKind::Decision.is_terminal("accepted"));
assert!(!is_hidden(RecordKind::Decision, "accepted"));
}
#[test]
fn confidence_known_set_matches_variants() {
use clap::ValueEnum;
let variants: BTreeSet<&str> = Confidence::value_variants()
.iter()
.map(|v| v.as_str())
.collect();
let known: BTreeSet<&str> = Confidence::KNOWN.iter().copied().collect();
assert_eq!(variants, known);
}
#[test]
fn basis_known_set_matches_variants() {
use clap::ValueEnum;
let variants: BTreeSet<&str> = Basis::value_variants().iter().map(|v| v.as_str()).collect();
let known: BTreeSet<&str> = Basis::KNOWN.iter().copied().collect();
assert_eq!(variants, known);
}
#[test]
fn constraint_source_known_set_matches_variants() {
use clap::ValueEnum;
let variants: BTreeSet<&str> = ConstraintSource::value_variants()
.iter()
.map(|v| v.as_str())
.collect();
let known: BTreeSet<&str> = ConstraintSource::KNOWN.iter().copied().collect();
assert_eq!(variants, known);
}
#[test]
fn seeded_facet_maps_empty_to_absent_per_kind() {
for kind in RecordKind::ALL {
let seed = render_record_toml_seed(kind, 1, "s", "T", "2026-06-08").unwrap();
let record = validate(toml::from_str::<RawRecordToml>(&seed).unwrap()).unwrap();
assert_eq!(
record.status,
kind.default_status(),
"{kind:?}: seeded status"
);
assert!(record.evidence.supports.is_empty());
assert!(record.evidence.contradicts.is_empty());
assert!(record.evidence.notes.is_empty());
match &record.facet {
RecordFacet::Assumption(f) => {
assert_eq!(
f,
&AssumptionFacet::default(),
"{kind:?}: empty facet absent"
);
}
RecordFacet::Decision(f) => {
assert_eq!(f, &DecisionFacet::default());
}
RecordFacet::Question(f) => {
assert_eq!(f, &QuestionFacet::default());
}
RecordFacet::Constraint(f) => {
assert_eq!(f, &ConstraintFacet::default());
}
}
}
}
#[test]
fn non_empty_facet_enums_parse_to_their_variants() {
let assessed = "\
id = 1
slug = \"a\"
title = \"A\"
record_kind = \"assumption\"
status = \"testing\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
claim = \"tokens expire in 1h\"
confidence = \"high\"
basis = \"observation\"
validation_plan = \"probe the IdP\"
validated_by = \"\"
validated_on = \"\"
invalidated_by = \"\"
invalidated_on = \"\"
[evidence]
supports = [\"DEC-005-C\"]
contradicts = []
notes = [\"see the audit\"]
";
let record = validate(toml::from_str::<RawRecordToml>(assessed).unwrap()).unwrap();
match record.facet {
RecordFacet::Assumption(f) => {
assert_eq!(f.claim.as_deref(), Some("tokens expire in 1h"));
assert_eq!(f.confidence, Some(Confidence::High));
assert_eq!(f.basis, Some(Basis::Observation));
assert_eq!(f.validation_plan.as_deref(), Some("probe the IdP"));
assert_eq!(f.validated_by, None);
}
_ => panic!("expected an assumption facet"),
}
assert_eq!(record.evidence.supports, vec!["DEC-005-C"]);
assert_eq!(record.evidence.notes, vec!["see the audit"]);
}
#[test]
fn validate_errors_on_an_unknown_facet_enum_token() {
let body = "\
id = 1
slug = \"a\"
title = \"A\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
confidence = \"bogus\"
";
let raw: RawRecordToml = toml::from_str(body).unwrap();
assert!(
validate(raw).is_err(),
"an unknown confidence token is rejected"
);
}
fn populated_fixture(kind: RecordKind) -> String {
let head = format!(
"schema = \"doctrine.knowledge\"\nversion = 1\n\nid = 7\nslug = \"token-expiry\"\ntitle = \"Token expiry\"\nrecord_kind = \"{}\"\nstatus = {}\ncreated = \"2026-06-08\"\nupdated = \"2026-06-09\"\ntags = [\"auth\", \"security\"]\n",
kind.as_str(),
toml_string(kind.default_status()),
);
let facet = match kind {
RecordKind::Assumption => {
"\n[facet]\nclaim = \"tokens expire in 1h\"\nconfidence = \"high\"\nbasis = \"observation\"\nvalidation_plan = \"probe the IdP\"\nvalidated_by = \"david\"\nvalidated_on = \"2026-06-09\"\ninvalidated_by = \"\"\ninvalidated_on = \"\"\n"
}
RecordKind::Decision => {
"\n[facet]\ncontext = \"the import seam\"\nchoice = \"git cherry\"\nalternatives = [\"--merged\", \"delta-emptiness\"]\nrationale = \"patch-id is sound\"\nconsequences = [\"slower scan\", \"correct\"]\ndecided_by = \"david\"\ndecided_on = \"2026-06-09\"\n"
}
RecordKind::Question => {
"\n[facet]\nquestion = \"do we re-anchor B?\"\nwhy_matters = \"the delta corrupts otherwise\"\nanswer = \"yes, on a disjointness proof\"\nanswered_by = \"david\"\nanswered_on = \"2026-06-09\"\n"
}
RecordKind::Constraint => {
"\n[facet]\nstatement = \"no disk in the pure layer\"\nsource = \"canon\"\napplies_to = [\"src/knowledge.rs\", \"src/backlog.rs\"]\nwaiver_reason = \"\"\nwaived_by = \"\"\nwaived_on = \"\"\n"
}
};
let evidence = "\n[evidence]\nsupports = [\"ADR-001\"]\ncontradicts = []\nnotes = [\"see §5\", \"and §9\"]\n";
format!("{head}{facet}{evidence}")
}
#[test]
fn populated_record_round_trips_byte_stable_per_kind() {
for kind in RecordKind::ALL {
let original = populated_fixture(kind);
let record = validate(toml::from_str::<RawRecordToml>(&original).unwrap()).unwrap();
let rendered = render_record_toml(&record);
assert_eq!(
rendered, original,
"{kind:?}: toml -> struct -> toml must be byte-stable"
);
let reparsed = validate(toml::from_str::<RawRecordToml>(&rendered).unwrap()).unwrap();
assert_eq!(
reparsed, record,
"{kind:?}: struct stable across the round-trip"
);
}
}
#[test]
fn populated_record_round_trips_into_shared_meta() {
let original = populated_fixture(RecordKind::Decision);
let meta: Meta = toml::from_str(&original).unwrap();
assert_eq!(
meta,
Meta {
id: 7,
slug: "token-expiry".to_string(),
title: "Token expiry".to_string(),
status: "proposed".to_string(),
}
);
}
#[test]
fn record_scaffold_lays_out_toml_md_symlink_per_kind() {
for kind in RecordKind::ALL {
let ctx = ctx_for(kind);
let fileset = record_scaffold(kind, &ctx).unwrap();
assert_eq!(fileset.len(), 3, "{kind:?}: toml + md + symlink");
let toml_body = match &fileset[0] {
Artifact::File { rel_path, body } => {
assert_eq!(rel_path, Path::new("003/record-003.toml"));
body
}
Artifact::Symlink { .. } => panic!("first artifact is the toml"),
};
assert!(toml_body.contains(&format!("record_kind = \"{}\"", kind.as_str())));
assert!(
toml_body.contains(&format!("status = \"{}\"", kind.default_status())),
"{kind:?}: scaffolded status == default_status (F-A2)"
);
let facet_at = toml_body.find("[facet]").expect("a [facet] block");
let evidence_at = toml_body.find("[evidence]").expect("an [evidence] block");
let tags_at = toml_body.find("tags = []").expect("seeded tags");
let relationships_at = toml_body
.find("[relationships]")
.expect("a [relationships] block");
assert!(tags_at < facet_at, "{kind:?}: meta before [facet]");
assert!(
facet_at < evidence_at,
"{kind:?}: [facet] before [evidence]"
);
assert!(
evidence_at < relationships_at,
"{kind:?}: [evidence] before [relationships]"
);
assert!(
!toml_body.contains("[[relation]]"),
"{kind:?}: Slice A seeds no [[relation]] block"
);
assert!(
toml_body.contains("supersedes = []"),
"{kind:?}: seeded supersedes"
);
assert!(
toml_body.contains("superseded_by = []"),
"{kind:?}: seeded superseded_by"
);
assert!(
!toml_body.contains("{{"),
"{kind:?}: no token survives render"
);
assert!(matches!(
&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("003/record-003.md")
&& body.contains(&format!("{}: Token expiry", ctx.canonical))
));
assert!(matches!(
&fileset[2],
Artifact::Symlink { rel_path, target }
if rel_path == Path::new("003-token-expiry") && target == "003"
));
}
}
#[test]
fn scaffold_escapes_hostile_title_and_slug() {
let title = crate::tomlfmt::HOSTILE_TITLE;
let slug = crate::tomlfmt::HOSTILE_SLUG;
let body =
render_record_toml_seed(RecordKind::Assumption, 7, slug, title, "2026-06-08").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(parsed.slug, slug);
assert_eq!(parsed.title, title);
}
#[test]
fn render_escapes_hostile_facet_values() {
let body = "\
id = 1
slug = \"s\"
title = \"T\"
record_kind = \"decision\"
status = \"proposed\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
context = \"a\\\"b\"
choice = \"\"
alternatives = [\"x\\\"y\"]
rationale = \"\"
consequences = []
decided_by = \"\"
decided_on = \"\"
[evidence]
supports = []
contradicts = []
notes = []
";
let record = validate(toml::from_str::<RawRecordToml>(body).unwrap()).unwrap();
let rendered = render_record_toml(&record);
let reparsed = validate(toml::from_str::<RawRecordToml>(&rendered).unwrap()).unwrap();
assert_eq!(reparsed, record);
}
fn seed_record(root: &Path, kind: RecordKind, id: u32, body: &str) {
let name = format!("{id:03}");
let dir = root.join(kind.kind().dir).join(&name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("record-{name}.toml")), body).unwrap();
}
#[test]
fn record_without_relation_block_has_empty_tier1() {
let root = std::env::temp_dir().join("doctrine-sl096-pt1-empty");
let _ = std::fs::remove_dir_all(&root);
let record = "\
schema = \"doctrine.knowledge\"
version = 1
id = 1
slug = \"test\"
title = \"Test\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
[evidence]
supports = []
contradicts = []
notes = []
";
seed_record(&root, RecordKind::Assumption, 1, record);
let r = read_record(&root, RecordKind::Assumption, 1).unwrap();
assert!(r.tier1.is_empty(), "no [[relation]] block → empty tier1");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn record_with_authored_relation_rows_populates_tier1() {
let root = std::env::temp_dir().join("doctrine-sl096-pt1-auth");
let _ = std::fs::remove_dir_all(&root);
let record = "\
schema = \"doctrine.knowledge\"
version = 1
id = 1
slug = \"test\"
title = \"Test\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
[evidence]
supports = []
contradicts = []
notes = []
[[relation]]
label = \"shapes\"
target = \"SL-001\"
[[relation]]
label = \"spawns\"
target = \"ISS-001\"
[[relation]]
label = \"governed_by\"
target = \"ADR-001\"
";
seed_record(&root, RecordKind::Assumption, 1, record);
let r = read_record(&root, RecordKind::Assumption, 1).unwrap();
assert_eq!(r.tier1.len(), 3);
assert_eq!(r.tier1[0].label, crate::relation::RelationLabel::Shapes);
assert_eq!(r.tier1[0].target, "SL-001");
assert_eq!(r.tier1[1].label, crate::relation::RelationLabel::Spawns);
assert_eq!(r.tier1[1].target, "ISS-001");
assert_eq!(r.tier1[2].label, crate::relation::RelationLabel::GovernedBy);
assert_eq!(r.tier1[2].target, "ADR-001");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn record_with_illegal_label_excludes_illegal_from_tier1() {
let root = std::env::temp_dir().join("doctrine-sl096-pt1-illegal");
let _ = std::fs::remove_dir_all(&root);
let record = "\
schema = \"doctrine.knowledge\"
version = 1
id = 1
slug = \"test\"
title = \"Test\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
[evidence]
supports = []
contradicts = []
notes = []
[[relation]]
label = \"supersedes\"
target = \"SL-001\"
[[relation]]
label = \"shapes\"
target = \"PRD-001\"
";
seed_record(&root, RecordKind::Assumption, 1, record);
let r = read_record(&root, RecordKind::Assumption, 1).unwrap();
assert_eq!(
r.tier1.len(),
2,
"supersedes now has a RECORD rule (LifecycleOnly), shapes is Writable — both in tier1"
);
assert_eq!(r.tier1[0].label, crate::relation::RelationLabel::Supersedes);
assert_eq!(r.tier1[0].target, "SL-001");
assert_eq!(r.tier1[1].label, crate::relation::RelationLabel::Shapes);
assert_eq!(r.tier1[1].target, "PRD-001");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn record_with_unknown_label_excludes_unknown_from_tier1() {
let root = std::env::temp_dir().join("doctrine-sl096-pt1-unknown");
let _ = std::fs::remove_dir_all(&root);
let record = "\
schema = \"doctrine.knowledge\"
version = 1
id = 1
slug = \"test\"
title = \"Test\"
record_kind = \"assumption\"
status = \"held\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
[evidence]
supports = []
contradicts = []
notes = []
[[relation]]
label = \"nonsense\"
target = \"X\"
[[relation]]
label = \"governed_by\"
target = \"ADR-001\"
";
seed_record(&root, RecordKind::Assumption, 1, record);
let r = read_record(&root, RecordKind::Assumption, 1).unwrap();
assert_eq!(
r.tier1.len(),
1,
"unknown nonsense label excluded, governed_by survives"
);
assert_eq!(r.tier1[0].label, crate::relation::RelationLabel::GovernedBy);
assert_eq!(r.tier1[0].target, "ADR-001");
let _ = std::fs::remove_dir_all(&root);
}
}