use super::{
validate_key, validate_repo_relative_path, validate_tag, Kind, Op, Record,
DETAILS_COMBINED_MAX_CHARS, DETAIL_LINE_MAX_CHARS, RATIONALE_MAX_CHARS, SUMMARY_MAX_CHARS,
};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Issue {
pub severity: Severity,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warning,
}
fn error(issues: &mut Vec<Issue>, message: impl Into<String>) {
issues.push(Issue {
severity: Severity::Error,
message: message.into(),
});
}
fn warning(issues: &mut Vec<Issue>, message: impl Into<String>) {
issues.push(Issue {
severity: Severity::Warning,
message: message.into(),
});
}
fn no_edge_spaces(issues: &mut Vec<Issue>, field: &str, value: &str) {
if value != value.trim() {
error(
issues,
format!("field '{field}' must not have leading or trailing spaces (breaks canonical round-trip)"),
);
}
}
pub fn validate_record(r: &Record) -> Vec<Issue> {
let mut issues = Vec::new();
if let Err(m) = validate_key(&r.key) {
error(&mut issues, format!("invalid key: {m}"));
}
let summary_chars = r.summary.chars().count();
if summary_chars == 0 {
error(&mut issues, "summary is required");
} else if summary_chars > SUMMARY_MAX_CHARS {
error(
&mut issues,
format!("summary exceeds {SUMMARY_MAX_CHARS} characters ({summary_chars})"),
);
}
no_edge_spaces(&mut issues, "summary", &r.summary);
if let Some(rat) = &r.rationale {
let n = rat.chars().count();
if n > RATIONALE_MAX_CHARS {
error(
&mut issues,
format!("rationale exceeds {RATIONALE_MAX_CHARS} characters ({n})"),
);
}
no_edge_spaces(&mut issues, "rationale", rat);
}
if r.kind.requires_rationale()
&& r.rationale.is_none()
&& r.extension("x-rationale-not-applicable").is_none()
{
error(
&mut issues,
format!(
"kind '{}' requires rationale (or an explicit x-rationale-not-applicable extension)",
r.kind.as_str()
),
);
}
let mut expanded_total = 0usize;
for (name, list) in [
("detail", &r.details),
("alternative", &r.alternatives),
("consequence", &r.consequences),
] {
for v in list {
let n = v.chars().count();
expanded_total += n;
if n > DETAIL_LINE_MAX_CHARS {
error(
&mut issues,
format!("{name} line exceeds {DETAIL_LINE_MAX_CHARS} characters"),
);
}
no_edge_spaces(&mut issues, name, v);
}
}
if expanded_total > DETAILS_COMBINED_MAX_CHARS {
error(
&mut issues,
format!("combined detail/alternative/consequence content exceeds {DETAILS_COMBINED_MAX_CHARS} characters"),
);
}
for p in &r.paths {
if let Err(m) = validate_repo_relative_path(p) {
error(&mut issues, format!("invalid path: {m}"));
}
}
for t in &r.tags {
if let Err(m) = validate_tag(t) {
error(&mut issues, format!("invalid tag: {m}"));
}
}
for e in &r.evidence {
if e.etype == super::EvidenceType::Path {
let path_part = e.value.split('@').next().unwrap_or(&e.value);
let path_part = path_part.split('#').next().unwrap_or(path_part);
if let Err(m) = validate_repo_relative_path(path_part) {
error(&mut issues, format!("invalid evidence path: {m}"));
}
}
}
if r.op == Op::Retract {
if r.supersedes.is_empty() {
error(
&mut issues,
"a retract record must supersede at least one record",
);
}
if r.rationale.is_none() && r.details.is_empty() {
error(
&mut issues,
"a retract record must explain the reason in rationale or detail",
);
}
}
if r.supersedes.contains(&r.id) {
error(&mut issues, "a record cannot supersede itself");
}
{
let mut seen = std::collections::HashSet::new();
for u in &r.supersedes {
if !seen.insert(u) {
error(&mut issues, format!("duplicate supersedes entry {u}"));
}
}
}
if r.kind == Kind::KeyAlias {
match (&r.alias_key, &r.canonical_key) {
(Some(alias), Some(canonical)) => {
if let Err(m) = validate_key(alias) {
error(&mut issues, format!("invalid alias-key: {m}"));
}
if let Err(m) = validate_key(canonical) {
error(&mut issues, format!("invalid canonical-key: {m}"));
}
if alias == canonical {
error(
&mut issues,
"alias-key must differ from canonical-key (self-alias)",
);
}
let expected = format!("key-alias.{alias}");
if r.key != expected {
error(
&mut issues,
format!(
"key of a key-alias record must be '{expected}', found '{}'",
r.key
),
);
}
}
_ => error(
&mut issues,
"kind 'key-alias' requires alias-key and canonical-key",
),
}
} else if r.alias_key.is_some() || r.canonical_key.is_some() {
error(
&mut issues,
"alias-key/canonical-key are only valid on kind 'key-alias'",
);
}
if r.kind.is_event() {
let prefix = format!("{}.", r.kind.as_str());
if !r.key.starts_with(&prefix) {
warning(
&mut issues,
format!("event record key should normally be '{}<uuid-v7>'", prefix),
);
}
}
if r.paths.is_empty()
&& r.symbols.is_empty()
&& r.tags.is_empty()
&& r.kind != Kind::KeyAlias
&& r.extension("x-repository-wide").is_none()
{
warning(
&mut issues,
"record has no path, symbol, or tag scope; add one or mark it repository-wide with x-repository-wide true",
);
}
for (name, v) in [("key", Some(&r.key)), ("writer", Some(&r.writer))] {
if let Some(v) = v {
no_edge_spaces(&mut issues, name, v);
}
}
for x in &r.extensions {
if !x.name.starts_with("x-") || x.name.len() <= 2 {
error(
&mut issues,
format!("extension field '{}' must begin with 'x-'", x.name),
);
}
}
issues
}
pub fn has_errors(issues: &[Issue]) -> bool {
issues.iter().any(|i| i.severity == Severity::Error)
}
#[cfg(test)]
mod tests {
use super::super::{Confidence, Evidence, EvidenceType, Extension, Op, Record};
use super::*;
use chrono::TimeZone;
use chrono::Utc;
use uuid::Uuid;
fn base(kind: Kind) -> Record {
Record {
id: Uuid::now_v7(),
key: "payments.webhook.retry-policy".into(),
kind,
op: Op::Assert,
summary: "Something true.".into(),
rationale: Some("Because reasons.".into()),
confidence: Confidence::Verified,
created_at: Utc.with_ymd_and_hms(2026, 7, 27, 0, 0, 0).unwrap(),
writer: "w1".into(),
human: None,
agent: None,
session: None,
pr: None,
issue: None,
alias_key: None,
canonical_key: None,
details: vec![],
alternatives: vec![],
consequences: vec![],
paths: vec!["apps/worker".into()],
symbols: vec![],
tags: vec![],
evidence: vec![],
supersedes: vec![],
related: vec![],
extensions: vec![],
}
}
#[test]
fn valid_decision_passes() {
let issues = validate_record(&base(Kind::Decision));
assert!(!has_errors(&issues), "{issues:?}");
}
#[test]
fn decision_without_rationale_fails() {
let mut r = base(Kind::Decision);
r.rationale = None;
assert!(has_errors(&validate_record(&r)));
r.extensions.push(Extension {
name: "x-rationale-not-applicable".into(),
value: "true".into(),
});
assert!(!has_errors(&validate_record(&r)));
}
#[test]
fn retract_needs_supersedes_and_reason() {
let mut r = base(Kind::Decision);
r.op = Op::Retract;
r.supersedes = vec![];
assert!(has_errors(&validate_record(&r)));
r.supersedes = vec![Uuid::now_v7()];
assert!(!has_errors(&validate_record(&r)));
}
#[test]
fn key_alias_shape_enforced() {
let mut r = base(Kind::KeyAlias);
r.rationale = Some("Concept fused.".into());
assert!(has_errors(&validate_record(&r))); r.alias_key = Some("auth.tokens".into());
r.canonical_key = Some("auth.token-storage".into());
r.key = "key-alias.auth.tokens".into();
assert!(
!has_errors(&validate_record(&r)),
"{:?}",
validate_record(&r)
);
r.canonical_key = Some("auth.tokens".into());
assert!(has_errors(&validate_record(&r))); }
#[test]
fn self_supersession_rejected() {
let mut r = base(Kind::Decision);
r.supersedes = vec![r.id];
assert!(has_errors(&validate_record(&r)));
}
#[test]
fn bad_evidence_path_rejected() {
let mut r = base(Kind::Decision);
r.evidence = vec![Evidence {
etype: EvidenceType::Path,
value: "../outside".into(),
}];
assert!(has_errors(&validate_record(&r)));
}
}