diff --git a/crates/coven-threads-core/examples/generate_phase5_retired_ward_corpus.rs b/crates/coven-threads-core/examples/generate_phase5_retired_ward_corpus.rs new file mode 100644 index 0000000..910bc94 --- /dev/null +++ b/crates/coven-threads-core/examples/generate_phase5_retired_ward_corpus.rs @@ -0,0 +1,8 @@ +//! Emit the public synthetic retired-Ward Phase 5 corpus as canonical JSON. + +#[path = "../tests/support/phase5_retired_ward_corpus.rs"] +mod corpus; + +fn main() { + println!("{}", corpus::canonical_corpus_json()); +} diff --git a/crates/coven-threads-core/src/surface_regions.rs b/crates/coven-threads-core/src/surface_regions.rs index 0c36263..02376f7 100644 --- a/crates/coven-threads-core/src/surface_regions.rs +++ b/crates/coven-threads-core/src/surface_regions.rs @@ -111,6 +111,18 @@ impl MaterializedDiff { surface.surface.as_str() )); } + if surface.before.is_none() && surface.after.is_none() { + return Err(format!( + "materialized diff surface {:?} has neither before nor after content", + surface.surface.as_str() + )); + } + if surface.before == surface.after { + return Err(format!( + "materialized diff surface {:?} is unchanged", + surface.surface.as_str() + )); + } } Ok(Self { surfaces }) } @@ -613,6 +625,30 @@ mod tests { assert!(MaterializedDiff::try_new(duplicate).is_err()); } + #[test] + fn materialized_diff_rejects_absent_before_and_after() { + let error = MaterializedDiff::try_new(vec![SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: None, + after: None, + }]) + .unwrap_err(); + + assert!(error.contains("neither before nor after content")); + } + + #[test] + fn materialized_diff_rejects_unchanged_surfaces() { + let error = MaterializedDiff::try_new(vec![SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"same".to_vec()), + after: Some(b"same".to_vec()), + }]) + .unwrap_err(); + + assert!(error.contains("unchanged")); + } + #[test] fn materialized_diff_deserialization_rejects_duplicate_surfaces() { let json = r#"{"surfaces":[ diff --git a/crates/coven-threads-core/tests/phase5_retired_ward_corpus.rs b/crates/coven-threads-core/tests/phase5_retired_ward_corpus.rs new file mode 100644 index 0000000..ca695c2 --- /dev/null +++ b/crates/coven-threads-core/tests/phase5_retired_ward_corpus.rs @@ -0,0 +1,239 @@ +//! Conformance coverage for the public synthetic retired-Ward corpus generator. + +#[path = "support/phase5_retired_ward_corpus.rs"] +mod corpus; + +use std::time::Duration; + +use coven_threads_core::approval::{ApprovalPath, ApprovalPathKind, VetoWindow}; +use coven_threads_core::identity_invariants::{ + CandidateIdentityFact, CandidateIdentityFacts, IdentityFact, IdentityInvariantSet, +}; +use coven_threads_core::ids::SurfaceId; +use coven_threads_core::pattern::WeaveCoherence; +use coven_threads_core::surface_regions::{MaterializedDiff, SurfaceDiff, SurfaceRegionRegistry}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +fn valid_cases() -> Vec { + corpus::synthetic_retired_ward_corpus()["valid_cases"] + .as_array() + .expect("valid_cases is an array") + .clone() +} + +fn unsupported_cases() -> Vec { + corpus::synthetic_retired_ward_corpus()["unsupported_cases"] + .as_array() + .expect("unsupported_cases is an array") + .clone() +} + +fn identity_fact(name: &str) -> IdentityFact { + match name { + "name" => IdentityFact::Name, + "person" => IdentityFact::Person, + "pronouns" => IdentityFact::Pronouns, + "purpose" => IdentityFact::Purpose, + "coven" => IdentityFact::Coven, + other => panic!("unsupported synthetic identity fact {other}"), + } +} + +fn approval_path(case: &Value) -> Result { + let approval = &case["approval"]; + let veto = || { + let veto = &approval["veto"]; + VetoWindow::try_new( + Duration::from_secs(veto["duration_seconds"].as_u64().unwrap()), + Duration::from_secs(veto["min_visible_seconds"].as_u64().unwrap()), + ) + }; + match approval["kind"].as_str().unwrap() { + "auto_regression" => Ok(ApprovalPath::AutoRegression { + veto: approval["veto"].is_object().then(veto).transpose()?, + }), + "familiar_coherence" => Ok(ApprovalPath::FamiliarCoherence { veto: veto()? }), + "human_approval" => Ok(ApprovalPath::HumanApproval), + "human_approval_with_rationale" => Ok(ApprovalPath::HumanApprovalWithRationale), + other => Err(format!("unsupported approval path kind {other}")), + } +} + +fn materialized_diff(case: &Value) -> Result { + let surfaces = case["surfaces"] + .as_array() + .unwrap() + .iter() + .map(|entry| SurfaceDiff { + surface: SurfaceId::new(entry["path"].as_str().unwrap()), + before: entry["before"] + .as_str() + .map(|value| value.as_bytes().to_vec()), + after: entry["after"] + .as_str() + .map(|value| value.as_bytes().to_vec()), + }) + .collect(); + MaterializedDiff::try_new(surfaces) +} + +#[test] +fn corpus_documents_synthetic_provenance_without_historical_data() { + let corpus = corpus::synthetic_retired_ward_corpus(); + assert_eq!(corpus["schema_version"], "phase5-retired-ward-synthetic-v1"); + assert_eq!(corpus["provenance"]["kind"], "synthetic"); + assert_eq!(corpus["provenance"]["historical_data_used"], false); + assert!(corpus["provenance"]["notice"] + .as_str() + .unwrap() + .contains("repository-authored")); +} + +#[test] +fn valid_cases_cover_identity_approval_veto_and_region_fidelity() { + let cases = valid_cases(); + assert_eq!(cases.len(), 4); + + let labels: Vec<_> = cases + .iter() + .map(|case| case["approval"]["label"].as_str().unwrap()) + .collect(); + assert_eq!( + labels, + ["auto", "familiar_review", "human_review", "human_required"] + ); + + let mut covered_regions = Vec::new(); + for case in cases { + let declarations: Vec<_> = case["declarations"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect(); + let invariants = IdentityInvariantSet::compile(declarations).unwrap(); + assert_eq!(invariants.declarations().len(), 5); + + let commitment = [case["candidate_commitment_byte"].as_u64().unwrap() as u8; 32]; + let facts = case["candidate_facts"] + .as_array() + .unwrap() + .iter() + .map(|entry| CandidateIdentityFact { + fact: identity_fact(entry["fact"].as_str().unwrap()), + value: entry["value"].as_str().unwrap().to_string(), + }) + .collect(); + let facts = CandidateIdentityFacts::try_new(commitment, facts).unwrap(); + assert!(matches!( + invariants.evaluate(commitment, Some(&facts)), + WeaveCoherence::Coherent + )); + + let approval = approval_path(&case).unwrap(); + assert_eq!(approval.display_label(), case["approval"]["label"]); + assert_eq!( + ApprovalPath::from_display_label(approval.display_label()), + Some(match case["approval"]["kind"].as_str().unwrap() { + "auto_regression" => ApprovalPathKind::AutoRegression, + "familiar_coherence" => ApprovalPathKind::FamiliarCoherence, + "human_approval" => ApprovalPathKind::HumanApproval, + "human_approval_with_rationale" => { + ApprovalPathKind::HumanApprovalWithRationale + } + _ => unreachable!(), + }) + ); + + let diff = materialized_diff(&case).unwrap(); + let evidence = SurfaceRegionRegistry::default_registry().classify_all(&diff); + let regions: Vec<_> = evidence + .iter() + .map(|entry| entry.region_id.as_str()) + .collect(); + assert_eq!( + regions, + case["expected"]["regions"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect::>() + ); + assert_eq!( + SurfaceRegionRegistry::path_tier_floor(&evidence), + case["expected"]["path_tier_floor"].as_u64().unwrap() as u8 + ); + covered_regions.extend(regions.into_iter().map(str::to_string)); + } + + covered_regions.sort(); + covered_regions.dedup(); + assert_eq!( + covered_regions, + ["execution_prompt", "heartbeat_behavior", "tool_defaults"] + ); +} + +#[test] +fn unsupported_cases_fail_closed_for_each_authority_input() { + let cases = unsupported_cases(); + assert_eq!(cases.len(), 6); + + for case in cases { + let error = match case["kind"].as_str().unwrap() { + "identity_declaration" => { + let declarations = case["declarations"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()); + IdentityInvariantSet::compile(declarations) + .unwrap_err() + .join("; ") + } + "candidate_facts" => { + let source = valid_cases().remove(0); + let declarations = source["declarations"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()); + let invariants = IdentityInvariantSet::compile(declarations).unwrap(); + let commitment = [0x41; 32]; + let facts = case["candidate_facts"] + .as_array() + .unwrap() + .iter() + .map(|entry| CandidateIdentityFact { + fact: identity_fact(entry["fact"].as_str().unwrap()), + value: entry["value"].as_str().unwrap().to_string(), + }) + .collect(); + let facts = CandidateIdentityFacts::try_new(commitment, facts).unwrap(); + match invariants.evaluate(commitment, Some(&facts)) { + WeaveCoherence::Broken { reason, .. } => reason, + other => panic!("expected fail-closed identity result, got {other:?}"), + } + } + "veto_window" => approval_path(&case).unwrap_err(), + "materialized_diff" => materialized_diff(&case).unwrap_err(), + other => panic!("unsupported synthetic failure kind {other}"), + }; + assert!( + error.contains(case["expected_error_contains"].as_str().unwrap()), + "{error:?} did not match {:?}", + case["expected_error_contains"] + ); + } +} + +#[test] +fn canonical_generator_output_has_a_pinned_sha256_digest() { + let digest = Sha256::digest(corpus::canonical_corpus_json().as_bytes()); + assert_eq!( + format!("{digest:x}"), + "b3c5f156896ed4ef03b3f57bb8e65a33a5cf6fe52582ccd8403972a20299db44" + ); +} diff --git a/crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs b/crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs new file mode 100644 index 0000000..63968c8 --- /dev/null +++ b/crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs @@ -0,0 +1,228 @@ +use serde_json::{json, Value}; + +fn declarations(name: &str, person: &str, purpose: &str) -> Value { + json!([ + format!("familiar.name == {name:?}"), + format!("familiar.person == {person:?}"), + "familiar.pronouns == \"they/them\"", + format!("familiar.purpose includes {purpose:?}"), + "familiar.coven == \"ExampleCoven\"" + ]) +} + +fn candidate_facts(name: &str, person: &str, purpose: &str) -> Value { + json!([ + {"fact": "name", "value": name}, + {"fact": "person", "value": person}, + {"fact": "pronouns", "value": "they/them"}, + {"fact": "purpose", "value": format!("This synthetic familiar exists to {purpose}.")}, + {"fact": "coven", "value": "ExampleCoven"} + ]) +} + +fn valid_case( + id: &str, + commitment_byte: u8, + approval: Value, + surfaces: Value, + regions: Value, + path_tier_floor: u8, +) -> Value { + let name = format!("Synthetic-{id}"); + let person = format!("Example principal {commitment_byte}"); + let purpose = format!("exercise {id} migration fidelity"); + json!({ + "id": id, + "declarations": declarations(&name, &person, &purpose), + "candidate_commitment_byte": commitment_byte, + "candidate_facts": candidate_facts(&name, &person, &purpose), + "approval": approval, + "surfaces": surfaces, + "expected": { + "regions": regions, + "path_tier_floor": path_tier_floor + } + }) +} + +pub fn synthetic_retired_ward_corpus() -> Value { + json!({ + "schema_version": "phase5-retired-ward-synthetic-v1", + "provenance": { + "kind": "synthetic", + "historical_data_used": false, + "authorization_basis": "Repository-authored synthetic values require no retired or private source corpus.", + "notice": "This repository-authored corpus is fictional and must not be represented as recovered Ward v0.1 user data.", + "generator": "crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs" + }, + "valid_cases": [ + valid_case( + "auto", + 0x11, + json!({ + "kind": "auto_regression", + "label": "auto", + "veto": { + "duration_seconds": 3600, + "min_visible_seconds": 900 + } + }), + json!([ + { + "path": "MEMORY.md", + "before": "synthetic memory v1", + "after": "synthetic memory v2" + } + ]), + json!([]), + u8::MAX + ), + valid_case( + "familiar-review", + 0x22, + json!({ + "kind": "familiar_coherence", + "label": "familiar_review", + "veto": { + "duration_seconds": 7200, + "min_visible_seconds": 1800 + } + }), + json!([ + { + "path": "TOOLS.md", + "before": "synthetic tool defaults v1", + "after": "synthetic tool defaults v2" + }, + { + "path": "HEARTBEAT.md", + "before": "synthetic heartbeat v1", + "after": "synthetic heartbeat v2" + } + ]), + json!(["tool_defaults", "heartbeat_behavior"]), + 1 + ), + valid_case( + "human-review", + 0x33, + json!({ + "kind": "human_approval", + "label": "human_review" + }), + json!([ + { + "path": "SOUL.md", + "before": "synthetic execution prompt v1", + "after": "synthetic execution prompt v2" + } + ]), + json!(["execution_prompt"]), + 0 + ), + valid_case( + "human-required", + 0x44, + json!({ + "kind": "human_approval_with_rationale", + "label": "human_required" + }), + json!([ + { + "path": "AGENTS.md", + "before": "synthetic agent prompt v1", + "after": "synthetic agent prompt v2" + }, + { + "path": "TOOLS.md", + "before": "synthetic tool defaults v1", + "after": "synthetic tool defaults v3" + } + ]), + json!(["execution_prompt", "tool_defaults"]), + 0 + ) + ], + "unsupported_cases": [ + { + "id": "unknown-identity-fact", + "kind": "identity_declaration", + "declarations": [ + "familiar.name == \"Synthetic-Unknown\"", + "familiar.person == \"Example principal\"", + "familiar.favorite_color == \"purple\"" + ], + "expected_error_contains": "unsupported identity fact" + }, + { + "id": "missing-candidate-pronouns", + "kind": "candidate_facts", + "candidate_facts": [ + {"fact": "name", "value": "Synthetic-auto"}, + {"fact": "person", "value": "Example principal 17"}, + {"fact": "purpose", "value": "This synthetic familiar exists to exercise auto migration fidelity."}, + {"fact": "coven", "value": "ExampleCoven"} + ], + "expected_error_contains": "Pronouns identity fact" + }, + { + "id": "invalid-veto-visibility", + "kind": "veto_window", + "approval": { + "kind": "familiar_coherence", + "label": "familiar_review", + "veto": { + "duration_seconds": 60, + "min_visible_seconds": 61 + } + }, + "expected_error_contains": "min_visible" + }, + { + "id": "duplicate-materialized-surface", + "kind": "materialized_diff", + "surfaces": [ + { + "path": "TOOLS.md", + "before": "synthetic v1", + "after": "synthetic v2" + }, + { + "path": "TOOLS.md", + "before": "synthetic v2", + "after": "synthetic v3" + } + ], + "expected_error_contains": "duplicate surface" + }, + { + "id": "absent-before-and-after", + "kind": "materialized_diff", + "surfaces": [ + { + "path": "SOUL.md", + "before": null, + "after": null + } + ], + "expected_error_contains": "neither before nor after content" + }, + { + "id": "unchanged-materialized-surface", + "kind": "materialized_diff", + "surfaces": [ + { + "path": "SOUL.md", + "before": "synthetic unchanged", + "after": "synthetic unchanged" + } + ], + "expected_error_contains": "unchanged" + } + ] + }) +} + +pub fn canonical_corpus_json() -> String { + serde_json::to_string(&synthetic_retired_ward_corpus()).expect("synthetic corpus serializes") +} diff --git a/specs/PHASE-5-APPROVAL-SEMANTICS.md b/specs/PHASE-5-APPROVAL-SEMANTICS.md index 7f686f3..b823926 100644 --- a/specs/PHASE-5-APPROVAL-SEMANTICS.md +++ b/specs/PHASE-5-APPROVAL-SEMANTICS.md @@ -397,6 +397,12 @@ Phase 5 opened 2026-07-18 (Val + Nova decision). Beads are live. migration coverage, and fidelity coverage proving the retired name, person, pronouns, purpose, and Coven-membership invariant shapes are either compiled deterministically or rejected explicitly. +- `threads-uqx.13` — Public synthetic retired-Ward corpus: a repository-authored, + digest-pinned generator covers the five normative identity fields, all four + approval labels, veto settings, built-in harness regions, and fail-closed + unsupported cases without using historical or private Ward data. Generate it + with `cargo run -q -p coven-threads-core --example + generate_phase5_retired_ward_corpus`. - `threads-uqx.9` — Nova sign-off: RFC round-trip, Gate-4 fail-closed proof, descriptor-not-authority review. - `threads-uqx.10` — Val freeze: Phase-5 design frozen or rejected.