1use std::io::Write;
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40
41pub const VERDICTS: [&str; 2] = ["ok", "failed"];
44
45pub const CHECK_KINDS: [&str; 2] = ["verification", "conformance"];
55
56pub const FOREIGN_KIND_PREFIX: &str = "x-";
63
64pub const INVALID_CHECK_FINDING_CODE: &str = "INVALID_CHECK_FINDING";
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum CheckKind {
70 Verification,
71 Conformance,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RecordKind {
78 Engine(CheckKind),
79 Foreign(String),
80}
81
82impl RecordKind {
83 pub fn from_wire(s: &str) -> Option<Self> {
88 if let Some(k) = CheckKind::from_wire(s) {
89 return Some(Self::Engine(k));
90 }
91 let name = s.strip_prefix(FOREIGN_KIND_PREFIX)?;
92 let well_formed = !name.is_empty()
93 && name
94 .chars()
95 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
96 && !name.starts_with('-')
97 && !name.ends_with('-');
98 well_formed.then(|| Self::Foreign(s.to_string()))
99 }
100
101 pub fn engine_kind(&self) -> Option<CheckKind> {
103 match self {
104 Self::Engine(k) => Some(*k),
105 Self::Foreign(_) => None,
106 }
107 }
108
109 pub fn as_wire(&self) -> &str {
111 match self {
112 Self::Engine(k) => k.as_str(),
113 Self::Foreign(s) => s.as_str(),
114 }
115 }
116
117 pub fn vocabulary_hint() -> String {
119 format!(
120 "{}, or a caller-declared `{FOREIGN_KIND_PREFIX}<name>` kind (lowercase letters, digits, hyphens) the engine records verbatim and never interprets",
121 CHECK_KINDS.join(", ")
122 )
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct CheckFinding {
134 pub code: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub section: Option<String>,
139 pub message: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub evidence: Option<String>,
144}
145
146impl CheckFinding {
147 pub const SHAPE: &'static str =
149 "{code: <non-empty>, message: <non-empty>, section?: <key>, evidence?: <text>}";
150
151 pub fn validate(&self) -> Result<(), String> {
154 if self.code.trim().is_empty() {
155 return Err(format!(
156 "finding.code is required and must be non-empty — shape {}",
157 Self::SHAPE
158 ));
159 }
160 if self.message.trim().is_empty() {
161 return Err(format!(
162 "finding.message is required and must be non-empty — shape {}",
163 Self::SHAPE
164 ));
165 }
166 if self.section.as_deref().is_some_and(|s| s.trim().is_empty()) {
167 return Err(format!(
168 "finding.section, when given, must be non-empty — shape {}",
169 Self::SHAPE
170 ));
171 }
172 if self
173 .evidence
174 .as_deref()
175 .is_some_and(|s| s.trim().is_empty())
176 {
177 return Err(format!(
178 "finding.evidence, when given, must be non-empty — shape {}",
179 Self::SHAPE
180 ));
181 }
182 Ok(())
183 }
184
185 pub fn from_json(value: serde_json::Value) -> Result<Self, String> {
189 let finding: CheckFinding = serde_json::from_value(value)
190 .map_err(|e| format!("finding does not match the shape {} ({e})", Self::SHAPE))?;
191 finding.validate()?;
192 Ok(finding)
193 }
194}
195
196impl CheckKind {
197 pub fn from_wire(s: &str) -> Option<Self> {
199 match s {
200 "verification" => Some(Self::Verification),
201 "conformance" => Some(Self::Conformance),
202 _ => None,
203 }
204 }
205
206 pub fn as_str(self) -> &'static str {
207 match self {
208 Self::Verification => "verification",
209 Self::Conformance => "conformance",
210 }
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum Verdict {
217 Ok,
218 Failed,
219}
220
221impl Verdict {
222 pub fn from_wire(s: &str) -> Option<Self> {
224 match s {
225 "ok" => Some(Self::Ok),
226 "failed" => Some(Self::Failed),
227 _ => None,
228 }
229 }
230
231 pub fn as_str(self) -> &'static str {
232 match self {
233 Self::Ok => "ok",
234 Self::Failed => "failed",
235 }
236 }
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct CheckRecord {
242 pub ts: u64,
244 pub entity: String,
246 pub verdict: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub method: Option<String>,
252 pub entity_hash: String,
255 pub actor: String,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub client: Option<String>,
260 pub role: String,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub identity: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub kind: Option<String>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub schema_ref: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub finding: Option<CheckFinding>,
290}
291
292impl CheckRecord {
293 pub fn resolved_kind(&self) -> Option<CheckKind> {
298 match self.kind.as_deref() {
299 None => Some(CheckKind::Verification),
300 Some(k) if k.starts_with(FOREIGN_KIND_PREFIX) => None,
301 Some(k) => Some(CheckKind::from_wire(k).unwrap_or(CheckKind::Verification)),
302 }
303 }
304
305 pub fn foreign_kind(&self) -> Option<&str> {
307 self.kind
308 .as_deref()
309 .filter(|k| k.starts_with(FOREIGN_KIND_PREFIX))
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum CheckState {
316 NeverChecked,
317 CheckedOk,
318 CheckFailed,
319 CheckStale,
320}
321
322impl CheckState {
323 pub fn as_str(self) -> &'static str {
324 match self {
325 Self::NeverChecked => "never_checked",
326 Self::CheckedOk => "checked_ok",
327 Self::CheckFailed => "check_failed",
328 Self::CheckStale => "check_stale",
329 }
330 }
331}
332
333pub fn derive_state(latest: Option<&CheckRecord>, current_hash: &str) -> CheckState {
338 derive_state_pinned(latest, current_hash, None)
339}
340
341pub fn derive_state_pinned(
349 latest: Option<&CheckRecord>,
350 current_hash: &str,
351 current_schema_ref: Option<&str>,
352) -> CheckState {
353 match latest {
354 None => CheckState::NeverChecked,
355 Some(rec) if rec.entity_hash != current_hash => CheckState::CheckStale,
356 Some(rec)
357 if rec.schema_ref.is_some() && rec.schema_ref.as_deref() != current_schema_ref =>
358 {
359 CheckState::CheckStale
360 }
361 Some(rec) if rec.verdict == "failed" => CheckState::CheckFailed,
362 Some(_) => CheckState::CheckedOk,
363 }
364}
365
366fn checks_dir(workspace_root: &Path) -> PathBuf {
369 workspace_root
370 .join(crate::workspace_store::WORKSPACE_STORE_DIR)
371 .join("state")
372 .join("checks")
373}
374
375pub fn check_ledger_path(workspace_root: &Path) -> PathBuf {
377 checks_dir(workspace_root).join("checks.jsonl")
378}
379
380#[derive(Debug, Clone)]
382pub struct CheckLedger {
383 path: PathBuf,
384}
385
386impl CheckLedger {
387 pub fn for_workspace(workspace_root: &Path) -> Self {
388 Self {
389 path: check_ledger_path(workspace_root),
390 }
391 }
392
393 pub fn record(&self, rec: &CheckRecord) -> std::io::Result<()> {
398 if let Some(dir) = self.path.parent() {
399 std::fs::create_dir_all(dir)?;
400 }
401 let mut line = serde_json::to_string(rec).map_err(std::io::Error::other)?;
402 line.push('\n');
403 let mut f = std::fs::OpenOptions::new()
404 .create(true)
405 .append(true)
406 .open(&self.path)?;
407 f.write_all(line.as_bytes())
408 }
409
410 pub fn all(&self) -> Vec<CheckRecord> {
414 let Ok(content) = std::fs::read_to_string(&self.path) else {
415 return Vec::new();
416 };
417 content
418 .lines()
419 .filter_map(|l| serde_json::from_str(l).ok())
420 .collect()
421 }
422
423 pub fn latest_for(&self, entity: &str) -> Option<CheckRecord> {
427 self.all().into_iter().rev().find(|r| r.entity == entity)
428 }
429
430 pub fn latest_for_kind(&self, entity: &str, kind: CheckKind) -> Option<CheckRecord> {
434 self.all()
435 .into_iter()
436 .rev()
437 .find(|r| r.entity == entity && r.resolved_kind() == Some(kind))
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use tempfile::TempDir;
445
446 fn rec(entity: &str, verdict: &str, hash: &str) -> CheckRecord {
447 CheckRecord {
448 ts: 1,
449 entity: entity.to_string(),
450 verdict: verdict.to_string(),
451 method: None,
452 entity_hash: hash.to_string(),
453 actor: "cli".to_string(),
454 client: None,
455 role: "checker".to_string(),
456 identity: None,
457 kind: None,
458 schema_ref: None,
459 finding: None,
460 }
461 }
462
463 #[test]
464 fn state_derivation_covers_all_four_states() {
465 assert_eq!(derive_state(None, "h1"), CheckState::NeverChecked);
466 let ok = rec("m--e", "ok", "h1");
467 assert_eq!(derive_state(Some(&ok), "h1"), CheckState::CheckedOk);
468 assert_eq!(derive_state(Some(&ok), "h2"), CheckState::CheckStale);
469 let failed = rec("m--e", "failed", "h1");
470 assert_eq!(derive_state(Some(&failed), "h1"), CheckState::CheckFailed);
471 assert_eq!(derive_state(Some(&failed), "h2"), CheckState::CheckStale);
474 }
475
476 #[test]
477 fn ledger_appends_and_serves_newest_per_entity() {
478 let tmp = TempDir::new().unwrap();
479 let ledger = CheckLedger::for_workspace(tmp.path());
480 assert!(ledger.latest_for("m--a").is_none());
481 ledger.record(&rec("m--a", "failed", "h1")).unwrap();
482 ledger.record(&rec("m--b", "ok", "h9")).unwrap();
483 ledger.record(&rec("m--a", "ok", "h2")).unwrap();
484 let latest = ledger.latest_for("m--a").unwrap();
485 assert_eq!(latest.verdict, "ok");
486 assert_eq!(latest.entity_hash, "h2");
487 assert_eq!(ledger.all().len(), 3);
489 }
490
491 #[test]
492 fn verdict_vocabulary_is_closed() {
493 assert!(Verdict::from_wire("ok").is_some());
494 assert!(Verdict::from_wire("failed").is_some());
495 assert!(Verdict::from_wire("passed").is_none());
496 assert!(Verdict::from_wire("OK").is_none());
497 }
498
499 fn conf(entity: &str, verdict: &str, hash: &str, pin: &str) -> CheckRecord {
500 CheckRecord {
501 kind: Some("conformance".to_string()),
502 schema_ref: Some(pin.to_string()),
503 ..rec(entity, verdict, hash)
504 }
505 }
506
507 #[test]
508 fn kind_vocabulary_is_closed() {
509 assert!(CheckKind::from_wire("verification").is_some());
510 assert!(CheckKind::from_wire("conformance").is_some());
511 assert!(CheckKind::from_wire("semantic").is_none());
512 assert!(CheckKind::from_wire("Conformance").is_none());
513 }
514
515 #[test]
519 fn legacy_lines_read_as_verification() {
520 let legacy = r#"{"ts":1,"entity":"m--e","verdict":"ok","entity_hash":"h1","actor":"cli","role":"checker"}"#;
521 let parsed: CheckRecord = serde_json::from_str(legacy).unwrap();
522 assert_eq!(parsed.resolved_kind(), Some(CheckKind::Verification));
523 let fresh = rec("m--e", "ok", "h1");
526 let line = serde_json::to_string(&fresh).unwrap();
527 assert!(!line.contains("kind"));
528 assert!(!line.contains("schema_ref"));
529 assert!(!line.contains("identity"));
533 }
534
535 #[test]
538 fn latest_is_per_kind() {
539 let tmp = TempDir::new().unwrap();
540 let ledger = CheckLedger::for_workspace(tmp.path());
541 ledger.record(&rec("m--a", "ok", "h1")).unwrap();
542 ledger
543 .record(&conf("m--a", "failed", "h1", "planning@1.0.0"))
544 .unwrap();
545 let v = ledger
546 .latest_for_kind("m--a", CheckKind::Verification)
547 .unwrap();
548 assert_eq!(v.verdict, "ok");
549 let c = ledger
550 .latest_for_kind("m--a", CheckKind::Conformance)
551 .unwrap();
552 assert_eq!(c.verdict, "failed");
553 assert_eq!(c.schema_ref.as_deref(), Some("planning@1.0.0"));
554 }
555
556 #[test]
559 fn conformance_stales_on_pin_move_verification_does_not() {
560 let c = conf("m--e", "ok", "h1", "planning@1.0.0");
561 assert_eq!(
562 derive_state_pinned(Some(&c), "h1", Some("planning@1.0.0")),
563 CheckState::CheckedOk
564 );
565 assert_eq!(
566 derive_state_pinned(Some(&c), "h2", Some("planning@1.0.0")),
567 CheckState::CheckStale
568 );
569 assert_eq!(
570 derive_state_pinned(Some(&c), "h1", Some("planning@2.0.0")),
571 CheckState::CheckStale
572 );
573 assert_eq!(
575 derive_state_pinned(Some(&c), "h1", None),
576 CheckState::CheckStale
577 );
578 let v = rec("m--e", "ok", "h1");
580 assert_eq!(
581 derive_state_pinned(Some(&v), "h1", Some("planning@9.0.0")),
582 CheckState::CheckedOk
583 );
584 assert_eq!(derive_state(Some(&v), "h1"), CheckState::CheckedOk);
585 }
586
587 #[test]
590 fn finding_shape_is_fixed_and_validated_whole() {
591 let ok = CheckFinding::from_json(serde_json::json!({
592 "code": "hidden-premise", "message": "The step assumes X.", "section": "step"
593 }))
594 .unwrap();
595 assert_eq!(ok.code, "hidden-premise");
596 assert_eq!(ok.section.as_deref(), Some("step"));
597 for bad in [
598 serde_json::json!({ "message": "no code" }),
599 serde_json::json!({ "code": "x" }),
600 serde_json::json!({ "code": "", "message": "empty code" }),
601 serde_json::json!({ "code": "x", "message": " " }),
602 serde_json::json!({ "code": "x", "message": "m", "severity": "high" }),
603 serde_json::json!({ "code": "x", "message": "m", "section": "" }),
604 ] {
605 let err = CheckFinding::from_json(bad.clone()).unwrap_err();
606 assert!(err.contains("shape"), "{bad}: {err}");
607 }
608 }
609
610 #[test]
611 fn open_kinds_parse_only_with_the_prefix_and_never_resolve_to_an_engine_kind() {
612 assert_eq!(
613 RecordKind::from_wire("verification"),
614 Some(RecordKind::Engine(CheckKind::Verification))
615 );
616 assert_eq!(
617 RecordKind::from_wire("x-step-walk"),
618 Some(RecordKind::Foreign("x-step-walk".to_string()))
619 );
620 for bad in ["step-walk", "x-", "x-Step", "x--a", "x-a-", "X-a"] {
621 assert!(RecordKind::from_wire(bad).is_none(), "{bad}");
622 }
623 assert!(RecordKind::vocabulary_hint().contains("x-<name>"));
624 let mut r = rec("m--e", "ok", "h");
625 r.kind = Some("x-step-walk".to_string());
626 assert_eq!(r.resolved_kind(), None);
627 assert_eq!(r.foreign_kind(), Some("x-step-walk"));
628 r.kind = None;
629 assert_eq!(r.resolved_kind(), Some(CheckKind::Verification));
630 r.kind = Some("conformance".to_string());
631 assert_eq!(r.resolved_kind(), Some(CheckKind::Conformance));
632 r.kind = Some("mystery".to_string());
634 assert_eq!(r.resolved_kind(), Some(CheckKind::Verification));
635 }
636
637 #[test]
638 fn pre_finding_ledger_lines_parse_and_derive_unchanged_and_findings_round_trip() {
639 let tmp = TempDir::new().unwrap();
640 let ledger = CheckLedger::for_workspace(tmp.path());
641 let dir = check_ledger_path(tmp.path());
642 std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
643 std::fs::write(
645 &dir,
646 "{\"ts\":1,\"entity\":\"m--e\",\"verdict\":\"failed\",\"entity_hash\":\"h\",\"actor\":\"cli\",\"role\":\"unspecified\"}\n",
647 )
648 .unwrap();
649 let old = ledger
650 .latest_for_kind("m--e", CheckKind::Verification)
651 .unwrap();
652 assert!(old.finding.is_none());
653 assert_eq!(derive_state(Some(&old), "h"), CheckState::CheckFailed);
654
655 let mut with = rec("m--e", "failed", "h");
656 with.ts = 2;
657 with.finding = Some(CheckFinding {
658 code: "hidden-premise".into(),
659 section: Some("step".into()),
660 message: "The step assumes X.".into(),
661 evidence: None,
662 });
663 ledger.record(&with).unwrap();
664 let mut foreign = rec("m--e", "ok", "h");
666 foreign.ts = 3;
667 foreign.kind = Some("x-step-walk".into());
668 ledger.record(&foreign).unwrap();
669 let latest = ledger
670 .latest_for_kind("m--e", CheckKind::Verification)
671 .unwrap();
672 assert_eq!(
673 latest.ts, 2,
674 "the foreign record is not the latest verification record"
675 );
676 assert_eq!(latest.finding.as_ref().unwrap().code, "hidden-premise");
677 assert_eq!(derive_state(Some(&latest), "h"), CheckState::CheckFailed);
678 let text = std::fs::read_to_string(&dir).unwrap();
679 assert!(text.contains("\"finding\":{\"code\":\"hidden-premise\",\"section\":\"step\",\"message\":\"The step assumes X.\"}"), "{text}");
680 assert!(text.contains("\"kind\":\"x-step-walk\""));
681 assert_eq!(text.lines().count(), 3, "append-only");
682 }
683}