From cc27ec0d2e3297aed4e7282a6041475b3c199b7a Mon Sep 17 00:00:00 2001 From: rogu3bear Date: Sun, 14 Jun 2026 22:04:25 -0500 Subject: [PATCH] feat(standards): add doctrine-quartet law Add a sixth V0 law, doctrine-quartet, so the workspace contract quartet (NORTH_STAR.md, ANCHOR.md, AGENTS.md, CLAUDE.md) is enforced by the standards plane instead of living only as prose. Missing quartet files become P2 findings anchored at the repo doc, with file:line evidence and a repair recommendation, so standards sprinkle down per repo and cannot drift silently. - catalog/laws.toml: register doctrine-quartet (pilot). - DocsInfo + scan_docs: track NORTH_STAR.md and ANCHOR.md alongside the existing AGENTS/CLAUDE/README/SECURITY scan. - audit_doctrine_quartet: new scanner wired into audit_repos, skipping Template/Unknown repos; requirement/expected mappings added. - repo explain: surface NORTH_STAR/ANCHOR presence. - tests: scanner fires on missing files, stays clean on a full quartet. Verified: cargo fmt --check, clippy -D warnings, 26 tests pass; live 'standards audit ~/dev --all' over 26 repos reports zero doctrine-quartet findings (forest is fully quartet-covered). --- catalog/laws.toml | 6 ++++ src/lib.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/catalog/laws.toml b/catalog/laws.toml index b9f5f1b..f39b77a 100644 --- a/catalog/laws.toml +++ b/catalog/laws.toml @@ -35,3 +35,9 @@ id = "repo-contract" title = "Repo contract law" description = "Active repos must resolve to a typed devctl contract whose archetype, posture, commands, release evidence, and artifact classes match observed repo reality." maturity = "pilot" + +[[laws]] +id = "doctrine-quartet" +title = "Doctrine quartet law" +description = "Every real repo must carry the contract quartet — NORTH_STAR.md, ANCHOR.md, AGENTS.md, CLAUDE.md — so workspace standards sprinkle down per repo and cannot drift silently. Missing quartet files are findings with file:line evidence at the repo anchor." +maturity = "pilot" diff --git a/src/lib.rs b/src/lib.rs index e9c1764..9bc20eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,12 +11,13 @@ use std::time::{SystemTime, UNIX_EPOCH}; const SCHEMA_VERSION: &str = "0.1.0"; const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION"); -const V0_LAWS: [&str; 5] = [ +const V0_LAWS: [&str; 6] = [ "cloudflare-mutation", "token", "command-verification", "release-proof", "artifact-boundary", + "doctrine-quartet", ]; const CONTRACT_LAW: &str = "repo-contract"; @@ -408,6 +409,8 @@ pub struct GitInfo { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DocsInfo { + pub north_star: Option, + pub anchor: Option, pub agents: Option, pub claude: Option, pub readme: Option, @@ -1599,6 +1602,8 @@ fn infer_status(path: &Utf8Path) -> RepoStatus { fn scan_docs(path: &Utf8Path) -> DocsInfo { DocsInfo { + north_star: exists_path(path, "NORTH_STAR.md"), + anchor: exists_path(path, "ANCHOR.md"), agents: exists_path(path, "AGENTS.md"), claude: exists_path(path, "CLAUDE.md"), readme: exists_path(path, "README.md"), @@ -1834,6 +1839,7 @@ fn audit_repos( } audit_cloudflare(repo, &mut findings)?; audit_tokens(repo, &mut findings)?; + audit_doctrine_quartet(repo, &mut findings)?; audit_command_verification(repo, &mut findings)?; audit_release(repo, &mut findings)?; audit_artifacts(repo, &mut findings)?; @@ -1883,6 +1889,7 @@ fn scanner_requirement_id(finding: &Finding) -> &'static str { "cloudflare-governance" => "cloudflare-mutation-lane-classified", "cloudflare-token-law" => "cloudflare-parent-token-contained", "secret-file-permissions" => "secret-file-permissions-private", + "doctrine-quartet" => "contract-quartet-present", "verification-contract" => "canonical-verification-command-present", "script-ownership" => "script-state-classified", "release-proof" => "release-lane-proof-bound", @@ -1900,6 +1907,9 @@ fn scanner_expected(finding: &Finding) -> &'static str { "Parent Cloudflare tokens remain in allowlisted control-plane or rotation contexts" } "secret-file-permissions" => "Secret-bearing env files are not group/world readable", + "doctrine-quartet" => { + "Every real repo carries the NORTH_STAR/ANCHOR/AGENTS/CLAUDE contract quartet" + } "verification-contract" => "Active repos declare a canonical verification surface", "script-ownership" => { "Check/verify scripts have a gated/recovery/transitive/retired classification" @@ -2406,6 +2416,45 @@ fn audit_tokens(repo: &RepoRecord, findings: &mut Vec) -> DevResult<()> Ok(()) } +fn audit_doctrine_quartet(repo: &RepoRecord, findings: &mut Vec) -> DevResult<()> { + if matches!(repo.status, RepoStatus::Template | RepoStatus::Unknown) { + return Ok(()); + } + let quartet: [(&str, &Option); 4] = [ + ("NORTH_STAR.md", &repo.docs.north_star), + ("ANCHOR.md", &repo.docs.anchor), + ("AGENTS.md", &repo.docs.agents), + ("CLAUDE.md", &repo.docs.claude), + ]; + for (name, present) in quartet { + if present.is_some() { + continue; + } + let message = format!("Repo is missing contract quartet file {name}"); + let evidence = format!( + "{name} not found at repo root; workspace doctrine expects the full \ + NORTH_STAR/ANCHOR/AGENTS/CLAUDE quartet" + ); + let recommendation = format!( + "Author {name} from live repo truth so workspace standards sprinkle down \ + and stay enforceable." + ); + findings.push(Finding::new( + "doctrine-quartet", + Severity::P2, + repo, + repo_anchor(repo), + None, + &message, + &evidence, + &recommendation, + Confidence::High, + "doctrine-quartet", + )); + } + Ok(()) +} + fn audit_command_verification(repo: &RepoRecord, findings: &mut Vec) -> DevResult<()> { if matches!( repo.status, @@ -3255,7 +3304,9 @@ fn print_repo_human(repo: &RepoRecord) { println!("path: {}", repo.path); println!("status: {}", repo.status); println!( - "docs: AGENTS={} CLAUDE={} README={} SECURITY={}", + "docs: NORTH_STAR={} ANCHOR={} AGENTS={} CLAUDE={} README={} SECURITY={}", + repo.docs.north_star.is_some(), + repo.docs.anchor.is_some(), repo.docs.agents.is_some(), repo.docs.claude.is_some(), repo.docs.readme.is_some(), @@ -3364,6 +3415,40 @@ mod tests { ); } + #[test] + fn missing_quartet_files_produce_doctrine_findings() { + let fixture = TestRepo::new("doctrine-repo"); + fixture.write("AGENTS.md", "active repo\n"); + fixture.write("Cargo.toml", "[package]\nname = \"doctrine-repo\"\n"); + let messages: Vec = fixture + .audit() + .into_iter() + .filter(|finding| finding.law == "doctrine-quartet") + .map(|finding| finding.message) + .collect(); + // AGENTS.md is present, so only the other three quartet files are findings. + assert!(messages.iter().any(|m| m.contains("NORTH_STAR.md"))); + assert!(messages.iter().any(|m| m.contains("ANCHOR.md"))); + assert!(messages.iter().any(|m| m.contains("CLAUDE.md"))); + assert!(!messages.iter().any(|m| m.contains("AGENTS.md"))); + } + + #[test] + fn complete_quartet_produces_no_doctrine_finding() { + let fixture = TestRepo::new("complete-repo"); + fixture.write("NORTH_STAR.md", "north\n"); + fixture.write("ANCHOR.md", "anchor\n"); + fixture.write("AGENTS.md", "agents\n"); + fixture.write("CLAUDE.md", "claude\n"); + fixture.write("Cargo.toml", "[package]\nname = \"complete-repo\"\n"); + assert!( + !fixture + .audit() + .iter() + .any(|finding| finding.law == "doctrine-quartet") + ); + } + #[test] fn inventory_json_has_schema_version() { let fixture = TestWorkspace::new();