use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const VERDICTS: [&str; 2] = ["ok", "failed"];
pub const CHECK_KINDS: [&str; 2] = ["verification", "conformance"];
pub const FOREIGN_KIND_PREFIX: &str = "x-";
pub const INVALID_CHECK_FINDING_CODE: &str = "INVALID_CHECK_FINDING";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckKind {
Verification,
Conformance,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecordKind {
Engine(CheckKind),
Foreign(String),
}
impl RecordKind {
pub fn from_wire(s: &str) -> Option<Self> {
if let Some(k) = CheckKind::from_wire(s) {
return Some(Self::Engine(k));
}
let name = s.strip_prefix(FOREIGN_KIND_PREFIX)?;
let well_formed = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !name.starts_with('-')
&& !name.ends_with('-');
well_formed.then(|| Self::Foreign(s.to_string()))
}
pub fn engine_kind(&self) -> Option<CheckKind> {
match self {
Self::Engine(k) => Some(*k),
Self::Foreign(_) => None,
}
}
pub fn as_wire(&self) -> &str {
match self {
Self::Engine(k) => k.as_str(),
Self::Foreign(s) => s.as_str(),
}
}
pub fn vocabulary_hint() -> String {
format!(
"{}, or a caller-declared `{FOREIGN_KIND_PREFIX}<name>` kind (lowercase letters, digits, hyphens) the engine records verbatim and never interprets",
CHECK_KINDS.join(", ")
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CheckFinding {
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence: Option<String>,
}
impl CheckFinding {
pub const SHAPE: &'static str =
"{code: <non-empty>, message: <non-empty>, section?: <key>, evidence?: <text>}";
pub fn validate(&self) -> Result<(), String> {
if self.code.trim().is_empty() {
return Err(format!(
"finding.code is required and must be non-empty — shape {}",
Self::SHAPE
));
}
if self.message.trim().is_empty() {
return Err(format!(
"finding.message is required and must be non-empty — shape {}",
Self::SHAPE
));
}
if self.section.as_deref().is_some_and(|s| s.trim().is_empty()) {
return Err(format!(
"finding.section, when given, must be non-empty — shape {}",
Self::SHAPE
));
}
if self
.evidence
.as_deref()
.is_some_and(|s| s.trim().is_empty())
{
return Err(format!(
"finding.evidence, when given, must be non-empty — shape {}",
Self::SHAPE
));
}
Ok(())
}
pub fn from_json(value: serde_json::Value) -> Result<Self, String> {
let finding: CheckFinding = serde_json::from_value(value)
.map_err(|e| format!("finding does not match the shape {} ({e})", Self::SHAPE))?;
finding.validate()?;
Ok(finding)
}
}
impl CheckKind {
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"verification" => Some(Self::Verification),
"conformance" => Some(Self::Conformance),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Verification => "verification",
Self::Conformance => "conformance",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Ok,
Failed,
}
impl Verdict {
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"ok" => Some(Self::Ok),
"failed" => Some(Self::Failed),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckRecord {
pub ts: u64,
pub entity: String,
pub verdict: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub entity_hash: String,
pub actor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finding: Option<CheckFinding>,
}
impl CheckRecord {
pub fn resolved_kind(&self) -> Option<CheckKind> {
match self.kind.as_deref() {
None => Some(CheckKind::Verification),
Some(k) if k.starts_with(FOREIGN_KIND_PREFIX) => None,
Some(k) => Some(CheckKind::from_wire(k).unwrap_or(CheckKind::Verification)),
}
}
pub fn foreign_kind(&self) -> Option<&str> {
self.kind
.as_deref()
.filter(|k| k.starts_with(FOREIGN_KIND_PREFIX))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckState {
NeverChecked,
CheckedOk,
CheckFailed,
CheckStale,
}
impl CheckState {
pub fn as_str(self) -> &'static str {
match self {
Self::NeverChecked => "never_checked",
Self::CheckedOk => "checked_ok",
Self::CheckFailed => "check_failed",
Self::CheckStale => "check_stale",
}
}
}
pub fn derive_state(latest: Option<&CheckRecord>, current_hash: &str) -> CheckState {
derive_state_pinned(latest, current_hash, None)
}
pub fn derive_state_pinned(
latest: Option<&CheckRecord>,
current_hash: &str,
current_schema_ref: Option<&str>,
) -> CheckState {
match latest {
None => CheckState::NeverChecked,
Some(rec) if rec.entity_hash != current_hash => CheckState::CheckStale,
Some(rec)
if rec.schema_ref.is_some() && rec.schema_ref.as_deref() != current_schema_ref =>
{
CheckState::CheckStale
}
Some(rec) if rec.verdict == "failed" => CheckState::CheckFailed,
Some(_) => CheckState::CheckedOk,
}
}
fn checks_dir(workspace_root: &Path) -> PathBuf {
workspace_root
.join(crate::workspace_store::WORKSPACE_STORE_DIR)
.join("state")
.join("checks")
}
pub fn check_ledger_path(workspace_root: &Path) -> PathBuf {
checks_dir(workspace_root).join("checks.jsonl")
}
#[derive(Debug, Clone)]
pub struct CheckLedger {
path: PathBuf,
}
impl CheckLedger {
pub fn for_workspace(workspace_root: &Path) -> Self {
Self {
path: check_ledger_path(workspace_root),
}
}
pub fn record(&self, rec: &CheckRecord) -> std::io::Result<()> {
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut line = serde_json::to_string(rec).map_err(std::io::Error::other)?;
line.push('\n');
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
f.write_all(line.as_bytes())
}
pub fn all(&self) -> Vec<CheckRecord> {
let Ok(content) = std::fs::read_to_string(&self.path) else {
return Vec::new();
};
content
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
pub fn latest_for(&self, entity: &str) -> Option<CheckRecord> {
self.all().into_iter().rev().find(|r| r.entity == entity)
}
pub fn latest_for_kind(&self, entity: &str, kind: CheckKind) -> Option<CheckRecord> {
self.all()
.into_iter()
.rev()
.find(|r| r.entity == entity && r.resolved_kind() == Some(kind))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn rec(entity: &str, verdict: &str, hash: &str) -> CheckRecord {
CheckRecord {
ts: 1,
entity: entity.to_string(),
verdict: verdict.to_string(),
method: None,
entity_hash: hash.to_string(),
actor: "cli".to_string(),
client: None,
role: "checker".to_string(),
identity: None,
kind: None,
schema_ref: None,
finding: None,
}
}
#[test]
fn state_derivation_covers_all_four_states() {
assert_eq!(derive_state(None, "h1"), CheckState::NeverChecked);
let ok = rec("m--e", "ok", "h1");
assert_eq!(derive_state(Some(&ok), "h1"), CheckState::CheckedOk);
assert_eq!(derive_state(Some(&ok), "h2"), CheckState::CheckStale);
let failed = rec("m--e", "failed", "h1");
assert_eq!(derive_state(Some(&failed), "h1"), CheckState::CheckFailed);
assert_eq!(derive_state(Some(&failed), "h2"), CheckState::CheckStale);
}
#[test]
fn ledger_appends_and_serves_newest_per_entity() {
let tmp = TempDir::new().unwrap();
let ledger = CheckLedger::for_workspace(tmp.path());
assert!(ledger.latest_for("m--a").is_none());
ledger.record(&rec("m--a", "failed", "h1")).unwrap();
ledger.record(&rec("m--b", "ok", "h9")).unwrap();
ledger.record(&rec("m--a", "ok", "h2")).unwrap();
let latest = ledger.latest_for("m--a").unwrap();
assert_eq!(latest.verdict, "ok");
assert_eq!(latest.entity_hash, "h2");
assert_eq!(ledger.all().len(), 3);
}
#[test]
fn verdict_vocabulary_is_closed() {
assert!(Verdict::from_wire("ok").is_some());
assert!(Verdict::from_wire("failed").is_some());
assert!(Verdict::from_wire("passed").is_none());
assert!(Verdict::from_wire("OK").is_none());
}
fn conf(entity: &str, verdict: &str, hash: &str, pin: &str) -> CheckRecord {
CheckRecord {
kind: Some("conformance".to_string()),
schema_ref: Some(pin.to_string()),
..rec(entity, verdict, hash)
}
}
#[test]
fn kind_vocabulary_is_closed() {
assert!(CheckKind::from_wire("verification").is_some());
assert!(CheckKind::from_wire("conformance").is_some());
assert!(CheckKind::from_wire("semantic").is_none());
assert!(CheckKind::from_wire("Conformance").is_none());
}
#[test]
fn legacy_lines_read_as_verification() {
let legacy = r#"{"ts":1,"entity":"m--e","verdict":"ok","entity_hash":"h1","actor":"cli","role":"checker"}"#;
let parsed: CheckRecord = serde_json::from_str(legacy).unwrap();
assert_eq!(parsed.resolved_kind(), Some(CheckKind::Verification));
let fresh = rec("m--e", "ok", "h1");
let line = serde_json::to_string(&fresh).unwrap();
assert!(!line.contains("kind"));
assert!(!line.contains("schema_ref"));
assert!(!line.contains("identity"));
}
#[test]
fn latest_is_per_kind() {
let tmp = TempDir::new().unwrap();
let ledger = CheckLedger::for_workspace(tmp.path());
ledger.record(&rec("m--a", "ok", "h1")).unwrap();
ledger
.record(&conf("m--a", "failed", "h1", "planning@1.0.0"))
.unwrap();
let v = ledger
.latest_for_kind("m--a", CheckKind::Verification)
.unwrap();
assert_eq!(v.verdict, "ok");
let c = ledger
.latest_for_kind("m--a", CheckKind::Conformance)
.unwrap();
assert_eq!(c.verdict, "failed");
assert_eq!(c.schema_ref.as_deref(), Some("planning@1.0.0"));
}
#[test]
fn conformance_stales_on_pin_move_verification_does_not() {
let c = conf("m--e", "ok", "h1", "planning@1.0.0");
assert_eq!(
derive_state_pinned(Some(&c), "h1", Some("planning@1.0.0")),
CheckState::CheckedOk
);
assert_eq!(
derive_state_pinned(Some(&c), "h2", Some("planning@1.0.0")),
CheckState::CheckStale
);
assert_eq!(
derive_state_pinned(Some(&c), "h1", Some("planning@2.0.0")),
CheckState::CheckStale
);
assert_eq!(
derive_state_pinned(Some(&c), "h1", None),
CheckState::CheckStale
);
let v = rec("m--e", "ok", "h1");
assert_eq!(
derive_state_pinned(Some(&v), "h1", Some("planning@9.0.0")),
CheckState::CheckedOk
);
assert_eq!(derive_state(Some(&v), "h1"), CheckState::CheckedOk);
}
#[test]
fn finding_shape_is_fixed_and_validated_whole() {
let ok = CheckFinding::from_json(serde_json::json!({
"code": "hidden-premise", "message": "The step assumes X.", "section": "step"
}))
.unwrap();
assert_eq!(ok.code, "hidden-premise");
assert_eq!(ok.section.as_deref(), Some("step"));
for bad in [
serde_json::json!({ "message": "no code" }),
serde_json::json!({ "code": "x" }),
serde_json::json!({ "code": "", "message": "empty code" }),
serde_json::json!({ "code": "x", "message": " " }),
serde_json::json!({ "code": "x", "message": "m", "severity": "high" }),
serde_json::json!({ "code": "x", "message": "m", "section": "" }),
] {
let err = CheckFinding::from_json(bad.clone()).unwrap_err();
assert!(err.contains("shape"), "{bad}: {err}");
}
}
#[test]
fn open_kinds_parse_only_with_the_prefix_and_never_resolve_to_an_engine_kind() {
assert_eq!(
RecordKind::from_wire("verification"),
Some(RecordKind::Engine(CheckKind::Verification))
);
assert_eq!(
RecordKind::from_wire("x-step-walk"),
Some(RecordKind::Foreign("x-step-walk".to_string()))
);
for bad in ["step-walk", "x-", "x-Step", "x--a", "x-a-", "X-a"] {
assert!(RecordKind::from_wire(bad).is_none(), "{bad}");
}
assert!(RecordKind::vocabulary_hint().contains("x-<name>"));
let mut r = rec("m--e", "ok", "h");
r.kind = Some("x-step-walk".to_string());
assert_eq!(r.resolved_kind(), None);
assert_eq!(r.foreign_kind(), Some("x-step-walk"));
r.kind = None;
assert_eq!(r.resolved_kind(), Some(CheckKind::Verification));
r.kind = Some("conformance".to_string());
assert_eq!(r.resolved_kind(), Some(CheckKind::Conformance));
r.kind = Some("mystery".to_string());
assert_eq!(r.resolved_kind(), Some(CheckKind::Verification));
}
#[test]
fn pre_finding_ledger_lines_parse_and_derive_unchanged_and_findings_round_trip() {
let tmp = TempDir::new().unwrap();
let ledger = CheckLedger::for_workspace(tmp.path());
let dir = check_ledger_path(tmp.path());
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
std::fs::write(
&dir,
"{\"ts\":1,\"entity\":\"m--e\",\"verdict\":\"failed\",\"entity_hash\":\"h\",\"actor\":\"cli\",\"role\":\"unspecified\"}\n",
)
.unwrap();
let old = ledger
.latest_for_kind("m--e", CheckKind::Verification)
.unwrap();
assert!(old.finding.is_none());
assert_eq!(derive_state(Some(&old), "h"), CheckState::CheckFailed);
let mut with = rec("m--e", "failed", "h");
with.ts = 2;
with.finding = Some(CheckFinding {
code: "hidden-premise".into(),
section: Some("step".into()),
message: "The step assumes X.".into(),
evidence: None,
});
ledger.record(&with).unwrap();
let mut foreign = rec("m--e", "ok", "h");
foreign.ts = 3;
foreign.kind = Some("x-step-walk".into());
ledger.record(&foreign).unwrap();
let latest = ledger
.latest_for_kind("m--e", CheckKind::Verification)
.unwrap();
assert_eq!(
latest.ts, 2,
"the foreign record is not the latest verification record"
);
assert_eq!(latest.finding.as_ref().unwrap().code, "hidden-premise");
assert_eq!(derive_state(Some(&latest), "h"), CheckState::CheckFailed);
let text = std::fs::read_to_string(&dir).unwrap();
assert!(text.contains("\"finding\":{\"code\":\"hidden-premise\",\"section\":\"step\",\"message\":\"The step assumes X.\"}"), "{text}");
assert!(text.contains("\"kind\":\"x-step-walk\""));
assert_eq!(text.lines().count(), 3, "append-only");
}
}