From af51a669473b67bc693e81fa9ee4e907d38c861c Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 18:59:36 -0700 Subject: [PATCH 1/2] feat(holmes): add law evidence validation gate --- CHANGELOG.md | 8 + crates/wesley-holmes/README.md | 10 +- .../src/application/evidence_validation.rs | 97 ++++++ crates/wesley-holmes/src/application/mod.rs | 2 + crates/wesley-holmes/src/domain/diagnostic.rs | 12 + crates/wesley-holmes/src/domain/evidence.rs | 313 ++++++++++++++++++ crates/wesley-holmes/src/domain/mod.rs | 10 +- crates/wesley-holmes/src/domain/versioning.rs | 80 ++++- crates/wesley-holmes/src/lib.rs | 9 +- crates/wesley-holmes/src/ports/mod.rs | 30 +- crates/wesley-holmes/tests/foundation.rs | 192 ++++++++++- docs/BEARING.md | 17 +- 12 files changed, 753 insertions(+), 27 deletions(-) create mode 100644 crates/wesley-holmes/src/application/evidence_validation.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d74a3db..67991dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ### Added +- **Rust Holmes law evidence validation gate**: Extended the unpublished + `wesley-holmes` crate with collected law evidence validation results, + required-versus-optional bundle artifact validation, canonical provenance + hash/source checks, deprecated schema-version warnings, artifact-local + version checks for law diff/coverage/capability/manifest evidence, and an + application-layer validator that loads artifacts through deterministic ports + while reporting unavailable, unreadable, and oversized artifacts without + panics. - **Rust Holmes assurance foundation**: Added the unpublished `crates/wesley-holmes` workspace crate with a hexagonal module shell, domain dependency-boundary tests, deterministic port traits and fakes, a structured diff --git a/crates/wesley-holmes/README.md b/crates/wesley-holmes/README.md index a022832c..0d45da2e 100644 --- a/crates/wesley-holmes/README.md +++ b/crates/wesley-holmes/README.md @@ -2,9 +2,9 @@ `wesley-holmes` is the Rust foundation for Holmes law assurance work inside Wesley. It consumes Wesley-published law evidence, policy, witness, MCP, and -GitHub payload artifacts; validates their envelope shape and version posture; -and prepares deterministic diagnostics and reporting surfaces for later CLI, -API, and MCP interfaces. +GitHub payload artifacts; validates their envelope shape, provenance, artifact +availability, and version posture; and prepares deterministic diagnostics and +reporting surfaces for later CLI, API, and MCP interfaces. This crate is intentionally not published yet. It is a workspace implementation crate for the Holmes redesign described in the Wesley design packet: @@ -27,5 +27,5 @@ The crate follows the planned hexagonal boundary: surfaces. - `reporting`: future renderer-facing DTOs and report assembly helpers. -The current slice establishes the foundation only. No public Holmes CLI command -is exposed from Wesley yet. +The current implementation includes the first local law evidence validation +gate. No public Holmes CLI command is exposed from Wesley yet. diff --git a/crates/wesley-holmes/src/application/evidence_validation.rs b/crates/wesley-holmes/src/application/evidence_validation.rs new file mode 100644 index 00000000..a568b13a --- /dev/null +++ b/crates/wesley-holmes/src/application/evidence_validation.rs @@ -0,0 +1,97 @@ +//! Evidence-bundle validation application service. + +use crate::application::WeslawArtifactLocator; +use crate::domain::{ + ArtifactRef, HolmesDiagnostic, HolmesDiagnosticCode, HolmesLawEvidenceBundle, HolmesSeverity, + LawEvidenceValidationResult, LoadedArtifactMetadata, VersionRegistry, +}; +use crate::ports::ArtifactLoadPort; + +/// Validates Holmes law evidence bundles without performing assurance judgment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LawEvidenceValidator { + locator: WeslawArtifactLocator, + max_bytes: usize, +} + +impl LawEvidenceValidator { + /// Create a validator with a workspace artifact locator. + pub fn new(locator: WeslawArtifactLocator) -> Self { + Self { + locator, + max_bytes: 8 * 1024 * 1024, + } + } + + /// Return a validator with a deterministic artifact byte limit. + pub fn with_max_bytes(mut self, max_bytes: usize) -> Self { + self.max_bytes = max_bytes; + self + } + + /// Validate structure, provenance, and referenced artifact availability. + pub fn validate( + &self, + bundle: &HolmesLawEvidenceBundle, + artifact_loader: &impl ArtifactLoadPort, + version_registry: &VersionRegistry, + ) -> LawEvidenceValidationResult { + let structural = bundle.validate_structure(version_registry); + if !structural.diagnostics.is_empty() { + return structural; + } + + let mut diagnostics = Vec::new(); + let mut loaded_artifacts = Vec::new(); + + for bundle_artifact in bundle.artifact_refs() { + let artifact = bundle_artifact.artifact; + let resolved = match self.locator.resolve(&artifact.path) { + Ok(resolved) => resolved, + Err(diagnostic) => { + diagnostics.push(diagnostic.at_field(bundle_artifact.field_path)); + continue; + } + }; + + let normalized = ArtifactRef { + path: resolved.workspace_relative.clone(), + schema_version: artifact.schema_version.clone(), + sha256: artifact.sha256.clone(), + }; + + match artifact_loader.read_artifact(&normalized) { + Ok(bytes) if bytes.len() <= self.max_bytes => { + loaded_artifacts.push(LoadedArtifactMetadata { + field_path: bundle_artifact.field_path.to_owned(), + artifact_family: bundle_artifact.family.id().to_owned(), + path: normalized.path, + byte_len: bytes.len(), + }); + } + Ok(bytes) => diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawArtifactOversized, + HolmesSeverity::Error, + format!( + "artifact {:?} is {} bytes, above the configured {} byte limit", + normalized.path, + bytes.len(), + self.max_bytes + ), + ) + .for_family(bundle_artifact.family.id()) + .at_field(bundle_artifact.field_path), + ), + Err(diagnostic) => diagnostics.push( + diagnostic + .for_family(bundle_artifact.family.id()) + .at_field(bundle_artifact.field_path), + ), + } + } + + LawEvidenceValidationResult::from_diagnostics(diagnostics) + .with_loaded_artifacts(loaded_artifacts) + } +} diff --git a/crates/wesley-holmes/src/application/mod.rs b/crates/wesley-holmes/src/application/mod.rs index c212ab14..8040631d 100644 --- a/crates/wesley-holmes/src/application/mod.rs +++ b/crates/wesley-holmes/src/application/mod.rs @@ -1,5 +1,7 @@ //! Application services for deterministic Holmes law-assurance orchestration. mod artifact_locator; +mod evidence_validation; pub use artifact_locator::{ResolvedArtifactPath, WeslawArtifactLocator}; +pub use evidence_validation::LawEvidenceValidator; diff --git a/crates/wesley-holmes/src/domain/diagnostic.rs b/crates/wesley-holmes/src/domain/diagnostic.rs index c57b37be..b396e4bc 100644 --- a/crates/wesley-holmes/src/domain/diagnostic.rs +++ b/crates/wesley-holmes/src/domain/diagnostic.rs @@ -13,6 +13,8 @@ pub enum HolmesDiagnosticCode { HlawSchemaVersionMissing, /// A `schemaVersion` field was not valid semantic version syntax. HlawSchemaVersionMalformed, + /// A `schemaVersion` is accepted but deprecated. + HlawSchemaVersionDeprecated, /// A `schemaVersion` major version is not supported by this Holmes build. HlawSchemaVersionUnsupportedMajor, /// A `schemaVersion` minor version is newer than this Holmes build accepts. @@ -25,8 +27,18 @@ pub enum HolmesDiagnosticCode { HlawArtifactPathInvalid, /// A law evidence bundle was missing a required artifact reference. HlawEvidenceBundleInvalid, + /// A provenance hash was absent or blank. + HlawProvenanceHashMissing, + /// A provenance hash did not use canonical `sha256:<64 lowercase hex>` syntax. + HlawProvenanceHashMalformed, + /// A provenance source identity was absent or blank. + HlawProvenanceSourceMissing, /// A requested artifact was unavailable through its port. HlawArtifactUnavailable, + /// A requested artifact was present but unreadable through its port. + HlawArtifactUnreadable, + /// A requested artifact exceeded the configured byte limit. + HlawArtifactOversized, } /// Severity attached to a Holmes diagnostic. diff --git a/crates/wesley-holmes/src/domain/evidence.rs b/crates/wesley-holmes/src/domain/evidence.rs index a2842e40..b151bc21 100644 --- a/crates/wesley-holmes/src/domain/evidence.rs +++ b/crates/wesley-holmes/src/domain/evidence.rs @@ -1,8 +1,11 @@ //! Law evidence bundle model consumed by Holmes. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use super::diagnostic::{HolmesDiagnostic, HolmesDiagnosticCode, HolmesResult, HolmesSeverity}; +use super::versioning::{ArtifactFamily, VersionRegistry}; /// Workspace-relative reference to a Wesley-published artifact. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -33,6 +36,87 @@ impl ArtifactRef { } } +/// Whether a bundle artifact reference is required for validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ArtifactRequirement { + /// The artifact reference must be present and non-blank. + Required, + /// The artifact reference may be omitted, but must be valid when present. + Optional, +} + +/// A bundle artifact reference with its stable field path and family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BundleArtifactRef<'a> { + /// Stable field path in the evidence bundle. + pub field_path: &'static str, + /// Artifact family used for schema-version validation. + pub family: ArtifactFamily, + /// Requirement class for this reference. + pub requirement: ArtifactRequirement, + /// Referenced artifact. + pub artifact: &'a ArtifactRef, +} + +/// Validation status before Holmes performs assurance assessment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LawEvidenceValidationStatus { + /// Evidence is structurally valid and all required artifacts were readable. + Valid, + /// Evidence is usable, but carries non-fatal diagnostics. + ValidWithWarnings, + /// Evidence is invalid and assessment must not run. + Invalid, + /// A dependency failure prevented validation from completing. + InfrastructureError, +} + +/// Metadata captured for a loaded evidence artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadedArtifactMetadata { + /// Stable evidence-bundle field path for this artifact. + pub field_path: String, + /// Artifact-family identifier. + pub artifact_family: String, + /// Normalized workspace-relative path. + pub path: String, + /// Loaded byte length. + pub byte_len: usize, +} + +/// Collected evidence validation result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LawEvidenceValidationResult { + /// Validation status. + pub status: LawEvidenceValidationStatus, + /// Deterministically ordered diagnostics. + pub diagnostics: Vec, + /// Metadata for artifacts successfully loaded by the validation gate. + pub loaded_artifacts: Vec, +} + +impl LawEvidenceValidationResult { + /// Build a validation result from diagnostics. + pub fn from_diagnostics(diagnostics: Vec) -> Self { + let status = validation_status_for(&diagnostics); + Self { + status, + diagnostics, + loaded_artifacts: Vec::new(), + } + } + + /// Add loaded artifact metadata. + pub fn with_loaded_artifacts(mut self, loaded_artifacts: Vec) -> Self { + self.loaded_artifacts = loaded_artifacts; + self + } +} + /// Required artifact families in a Holmes law evidence bundle. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -88,6 +172,95 @@ pub struct HolmesLawEvidenceBundle { } impl HolmesLawEvidenceBundle { + /// Return all present required and optional artifact references. + pub fn artifact_refs(&self) -> Vec> { + let mut artifacts = vec![ + BundleArtifactRef { + field_path: "artifacts.lawDiff", + family: ArtifactFamily::LawDiff, + requirement: ArtifactRequirement::Required, + artifact: &self.artifacts.law_diff, + }, + BundleArtifactRef { + field_path: "artifacts.lawCoverage", + family: ArtifactFamily::LawCoverage, + requirement: ArtifactRequirement::Required, + artifact: &self.artifacts.law_coverage, + }, + BundleArtifactRef { + field_path: "artifacts.lawCapabilities", + family: ArtifactFamily::LawCapabilities, + requirement: ArtifactRequirement::Required, + artifact: &self.artifacts.law_capabilities, + }, + BundleArtifactRef { + field_path: "artifacts.contractBundleManifest", + family: ArtifactFamily::ContractBundleManifest, + requirement: ArtifactRequirement::Required, + artifact: &self.artifacts.contract_bundle_manifest, + }, + ]; + + if let Some(artifact) = &self.artifacts.policy { + artifacts.push(BundleArtifactRef { + field_path: "artifacts.policy", + family: ArtifactFamily::Policy, + requirement: ArtifactRequirement::Optional, + artifact, + }); + } + if let Some(artifact) = &self.artifacts.report { + artifacts.push(BundleArtifactRef { + field_path: "artifacts.report", + family: ArtifactFamily::Report, + requirement: ArtifactRequirement::Optional, + artifact, + }); + } + if let Some(artifact) = &self.artifacts.witness { + artifacts.push(BundleArtifactRef { + field_path: "artifacts.witness", + family: ArtifactFamily::AuditWitness, + requirement: ArtifactRequirement::Optional, + artifact, + }); + } + + artifacts + } + + /// Validate bundle shape, artifact references, schema versions, and provenance. + pub fn validate_structure( + &self, + version_registry: &VersionRegistry, + ) -> LawEvidenceValidationResult { + let mut diagnostics = Vec::new(); + + match version_registry.classify( + ArtifactFamily::EvidenceBundle, + Some(self.schema_version.as_str()), + ) { + Ok(version_check) => diagnostics.extend(version_check.diagnostics), + Err(diagnostic) => diagnostics.push(diagnostic), + } + + if self.bundle_id.trim().is_empty() { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawEvidenceBundleInvalid, + HolmesSeverity::Error, + "law evidence bundle is missing bundleId", + ) + .at_field("bundleId"), + ); + } + + self.validate_artifact_structure(version_registry, &mut diagnostics); + self.validate_provenance(&mut diagnostics); + + LawEvidenceValidationResult::from_diagnostics(diagnostics) + } + /// Validate that all required artifact references are present. pub fn validate_required_artifacts(&self) -> HolmesResult<()> { let required = [ @@ -116,4 +289,144 @@ impl HolmesLawEvidenceBundle { Ok(()) } + + fn validate_artifact_structure( + &self, + version_registry: &VersionRegistry, + diagnostics: &mut Vec, + ) { + let mut paths = BTreeMap::new(); + + for bundle_artifact in self.artifact_refs() { + let artifact = bundle_artifact.artifact; + if artifact.is_blank() { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawEvidenceBundleInvalid, + HolmesSeverity::Error, + match bundle_artifact.requirement { + ArtifactRequirement::Required => { + "law evidence bundle is missing a required artifact reference" + } + ArtifactRequirement::Optional => { + "law evidence bundle contains a blank optional artifact reference" + } + }, + ) + .for_family(bundle_artifact.family.id()) + .at_field(bundle_artifact.field_path), + ); + continue; + } + + let duplicate_of = + paths.insert(artifact.path.trim().to_owned(), bundle_artifact.field_path); + if let Some(first_field_path) = duplicate_of { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawEvidenceBundleInvalid, + HolmesSeverity::Error, + format!( + "artifact path duplicates {first_field_path}; each artifact role must point at distinct evidence" + ), + ) + .for_family(bundle_artifact.family.id()) + .at_field(bundle_artifact.field_path), + ); + } + + if let Some(schema_version) = artifact.schema_version.as_deref() { + let schema_field = format!("{}.schemaVersion", bundle_artifact.field_path); + match version_registry.classify(bundle_artifact.family, Some(schema_version)) { + Ok(version_check) => diagnostics.extend( + version_check + .diagnostics + .into_iter() + .map(|diagnostic| diagnostic.at_field(schema_field.clone())), + ), + Err(diagnostic) => diagnostics.push(diagnostic.at_field(schema_field)), + } + } + } + } + + fn validate_provenance(&self, diagnostics: &mut Vec) { + validate_required_sha256( + &self.provenance.schema_hash, + "provenance.schemaHash", + diagnostics, + ); + validate_required_sha256(&self.provenance.law_hash, "provenance.lawHash", diagnostics); + if let Some(policy_hash) = &self.provenance.policy_hash { + validate_required_sha256(policy_hash, "provenance.policyHash", diagnostics); + } + validate_required_sha256( + &self.provenance.bundle_hash, + "provenance.bundleHash", + diagnostics, + ); + + if self.provenance.source.trim().is_empty() { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawProvenanceSourceMissing, + HolmesSeverity::Error, + "law evidence bundle provenance source must not be blank", + ) + .at_field("provenance.source"), + ); + } + } +} + +fn validation_status_for(diagnostics: &[HolmesDiagnostic]) -> LawEvidenceValidationStatus { + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == HolmesSeverity::Error) + { + LawEvidenceValidationStatus::Invalid + } else if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == HolmesSeverity::Warning) + { + LawEvidenceValidationStatus::ValidWithWarnings + } else { + LawEvidenceValidationStatus::Valid + } +} + +fn validate_required_sha256( + value: &str, + field_path: &'static str, + diagnostics: &mut Vec, +) { + if value.trim().is_empty() { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawProvenanceHashMissing, + HolmesSeverity::Error, + "law evidence bundle provenance hash must not be blank", + ) + .at_field(field_path), + ); + } else if !is_canonical_sha256(value) { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawProvenanceHashMalformed, + HolmesSeverity::Error, + "law evidence bundle provenance hash must use sha256:<64 lowercase hex>", + ) + .at_field(field_path), + ); + } +} + +fn is_canonical_sha256(value: &str) -> bool { + let Some(hex) = value.strip_prefix("sha256:") else { + return false; + }; + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } diff --git a/crates/wesley-holmes/src/domain/mod.rs b/crates/wesley-holmes/src/domain/mod.rs index a37a57dd..8247204f 100644 --- a/crates/wesley-holmes/src/domain/mod.rs +++ b/crates/wesley-holmes/src/domain/mod.rs @@ -9,5 +9,11 @@ mod evidence; mod versioning; pub use diagnostic::{HolmesDiagnostic, HolmesDiagnosticCode, HolmesResult, HolmesSeverity}; -pub use evidence::{ArtifactRef, BundleProvenance, HolmesLawEvidenceBundle, LawEvidenceArtifacts}; -pub use versioning::{ArtifactFamily, ParsedSchemaVersion, VersionRegistry, VersionRequirement}; +pub use evidence::{ + ArtifactRef, ArtifactRequirement, BundleArtifactRef, BundleProvenance, HolmesLawEvidenceBundle, + LawEvidenceArtifacts, LawEvidenceValidationResult, LawEvidenceValidationStatus, + LoadedArtifactMetadata, +}; +pub use versioning::{ + ArtifactFamily, ParsedSchemaVersion, VersionCheck, VersionRegistry, VersionRequirement, +}; diff --git a/crates/wesley-holmes/src/domain/versioning.rs b/crates/wesley-holmes/src/domain/versioning.rs index 9e21d66f..94dcc2d4 100644 --- a/crates/wesley-holmes/src/domain/versioning.rs +++ b/crates/wesley-holmes/src/domain/versioning.rs @@ -12,6 +12,14 @@ use super::diagnostic::{HolmesDiagnostic, HolmesDiagnosticCode, HolmesResult, Ho pub enum ArtifactFamily { /// Law evidence bundle envelope. EvidenceBundle, + /// Machine-readable law diff artifact. + LawDiff, + /// Law coverage artifact. + LawCoverage, + /// Law capability summary artifact. + LawCapabilities, + /// Contract bundle manifest artifact. + ContractBundleManifest, /// Assurance policy artifact. Policy, /// Rendered or structured assurance report artifact. @@ -31,6 +39,10 @@ impl ArtifactFamily { pub fn id(self) -> &'static str { match self { ArtifactFamily::EvidenceBundle => "evidence-bundle", + ArtifactFamily::LawDiff => "law-diff", + ArtifactFamily::LawCoverage => "law-coverage", + ArtifactFamily::LawCapabilities => "law-capabilities", + ArtifactFamily::ContractBundleManifest => "contract-bundle-manifest", ArtifactFamily::Policy => "policy", ArtifactFamily::Report => "report", ArtifactFamily::AuditWitness => "audit-witness", @@ -40,9 +52,13 @@ impl ArtifactFamily { } } - fn all() -> [ArtifactFamily; 7] { + fn all() -> [ArtifactFamily; 11] { [ ArtifactFamily::EvidenceBundle, + ArtifactFamily::LawDiff, + ArtifactFamily::LawCoverage, + ArtifactFamily::LawCapabilities, + ArtifactFamily::ContractBundleManifest, ArtifactFamily::Policy, ArtifactFamily::Report, ArtifactFamily::AuditWitness, @@ -101,6 +117,9 @@ pub struct VersionRequirement { pub major: u64, /// Highest accepted minor version for the accepted major. pub max_minor: u64, + /// Highest accepted minor version that should be reported as deprecated. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated_minor_through: Option, } impl VersionRequirement { @@ -110,8 +129,25 @@ impl VersionRequirement { family, major, max_minor, + deprecated_minor_through: None, } } + + /// Mark accepted versions at or below a minor version as deprecated. + pub fn with_deprecated_minor_through(mut self, minor: u64) -> Self { + self.deprecated_minor_through = Some(minor); + self + } +} + +/// Schema-version validation details for an accepted artifact version. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionCheck { + /// Parsed schema version. + pub parsed: ParsedSchemaVersion, + /// Non-fatal diagnostics, such as deprecation warnings. + pub diagnostics: Vec, } /// Local registry of accepted artifact-family schema versions. @@ -131,6 +167,12 @@ impl VersionRegistry { } } + /// Return a registry with one requirement inserted or replaced. + pub fn with_requirement(mut self, requirement: VersionRequirement) -> Self { + self.requirements.insert(requirement.family, requirement); + self + } + /// Return the requirement for an artifact family. pub fn requirement(&self, family: ArtifactFamily) -> Option { self.requirements.get(&family).copied() @@ -142,6 +184,15 @@ impl VersionRegistry { family: ArtifactFamily, schema_version: Option<&str>, ) -> HolmesResult { + Ok(self.classify(family, schema_version)?.parsed) + } + + /// Validate and classify a schema version for an artifact family. + pub fn classify( + &self, + family: ArtifactFamily, + schema_version: Option<&str>, + ) -> HolmesResult { let Some(raw_version) = schema_version else { return Err(missing_version(family)); }; @@ -186,7 +237,32 @@ impl VersionRegistry { .at_field("schemaVersion")); } - Ok(parsed) + let mut diagnostics = Vec::new(); + if requirement + .deprecated_minor_through + .is_some_and(|minor| parsed.minor <= minor) + { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawSchemaVersionDeprecated, + HolmesSeverity::Warning, + format!( + "{} schemaVersion {}.{}.{} is accepted but deprecated", + family.id(), + parsed.major, + parsed.minor, + parsed.patch + ), + ) + .for_family(family.id()) + .at_field("schemaVersion"), + ); + } + + Ok(VersionCheck { + parsed, + diagnostics, + }) } } diff --git a/crates/wesley-holmes/src/lib.rs b/crates/wesley-holmes/src/lib.rs index 501b7070..9cd4d5d1 100644 --- a/crates/wesley-holmes/src/lib.rs +++ b/crates/wesley-holmes/src/lib.rs @@ -14,11 +14,12 @@ pub mod domain; pub mod ports; pub mod reporting; -pub use application::{ResolvedArtifactPath, WeslawArtifactLocator}; +pub use application::{LawEvidenceValidator, ResolvedArtifactPath, WeslawArtifactLocator}; pub use domain::{ - ArtifactFamily, ArtifactRef, BundleProvenance, HolmesDiagnostic, HolmesDiagnosticCode, - HolmesLawEvidenceBundle, HolmesResult, HolmesSeverity, LawEvidenceArtifacts, - ParsedSchemaVersion, VersionRegistry, VersionRequirement, + ArtifactFamily, ArtifactRef, ArtifactRequirement, BundleArtifactRef, BundleProvenance, + HolmesDiagnostic, HolmesDiagnosticCode, HolmesLawEvidenceBundle, HolmesResult, HolmesSeverity, + LawEvidenceArtifacts, LawEvidenceValidationResult, LawEvidenceValidationStatus, + LoadedArtifactMetadata, ParsedSchemaVersion, VersionCheck, VersionRegistry, VersionRequirement, }; pub use ports::{ ArtifactLoadPort, ArtifactWritePort, ClockPort, CommandIoPort, EchoReportRenderer, diff --git a/crates/wesley-holmes/src/ports/mod.rs b/crates/wesley-holmes/src/ports/mod.rs index 0d3453b5..778f7447 100644 --- a/crates/wesley-holmes/src/ports/mod.rs +++ b/crates/wesley-holmes/src/ports/mod.rs @@ -73,12 +73,20 @@ pub trait FilesystemPort { pub struct InMemoryArtifactStore { artifacts: BTreeMap>, writes: BTreeMap>, + unreadable: BTreeMap, } impl InMemoryArtifactStore { /// Insert a readable artifact. pub fn insert(&mut self, path: impl Into, bytes: impl Into>) { - self.artifacts.insert(path.into(), bytes.into()); + let path = path.into(); + self.unreadable.remove(&path); + self.artifacts.insert(path, bytes.into()); + } + + /// Mark an artifact path as present but unreadable. + pub fn mark_unreadable(&mut self, path: impl Into, reason: impl Into) { + self.unreadable.insert(path.into(), reason.into()); } /// Return bytes written to a path. @@ -89,6 +97,15 @@ impl InMemoryArtifactStore { impl ArtifactLoadPort for InMemoryArtifactStore { fn read_artifact(&self, artifact: &ArtifactRef) -> HolmesResult> { + if let Some(reason) = self.unreadable.get(&artifact.path) { + return Err(HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawArtifactUnreadable, + HolmesSeverity::Error, + format!("artifact {:?} is unreadable: {reason}", artifact.path), + ) + .at_field("path")); + } + self.artifacts.get(&artifact.path).cloned().ok_or_else(|| { HolmesDiagnostic::new( HolmesDiagnosticCode::HlawArtifactUnavailable, @@ -103,6 +120,7 @@ impl ArtifactLoadPort for InMemoryArtifactStore { impl ArtifactWritePort for InMemoryArtifactStore { fn write_artifact(&mut self, path: &str, bytes: &[u8]) -> HolmesResult<()> { let data = bytes.to_vec(); + self.unreadable.remove(path); self.writes.insert(path.to_owned(), data.clone()); self.artifacts.insert(path.to_owned(), data); Ok(()) @@ -111,6 +129,15 @@ impl ArtifactWritePort for InMemoryArtifactStore { impl FilesystemPort for InMemoryArtifactStore { fn read_workspace_file(&self, path: &str) -> HolmesResult> { + if let Some(reason) = self.unreadable.get(path) { + return Err(HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawArtifactUnreadable, + HolmesSeverity::Error, + format!("workspace file {path:?} is unreadable: {reason}"), + ) + .at_field("path")); + } + self.artifacts.get(path).cloned().ok_or_else(|| { HolmesDiagnostic::new( HolmesDiagnosticCode::HlawArtifactUnavailable, @@ -123,6 +150,7 @@ impl FilesystemPort for InMemoryArtifactStore { fn write_workspace_file(&mut self, path: &str, bytes: &[u8]) -> HolmesResult<()> { let data = bytes.to_vec(); + self.unreadable.remove(path); self.writes.insert(path.to_owned(), data.clone()); self.artifacts.insert(path.to_owned(), data); Ok(()) diff --git a/crates/wesley-holmes/tests/foundation.rs b/crates/wesley-holmes/tests/foundation.rs index f8991eb8..4ba58843 100644 --- a/crates/wesley-holmes/tests/foundation.rs +++ b/crates/wesley-holmes/tests/foundation.rs @@ -1,8 +1,9 @@ use wesley_holmes::{ ArtifactFamily, ArtifactLoadPort, ArtifactRef, ArtifactWritePort, BundleProvenance, ClockPort, CommandIoPort, EchoReportRenderer, FilesystemPort, FixedClock, HolmesDiagnosticCode, - HolmesLawEvidenceBundle, InMemoryArtifactStore, LawEvidenceArtifacts, McpResourcePort, - RecordingCommandIo, ReportRenderPort, Timestamp, VersionRegistry, WeslawArtifactLocator, + HolmesLawEvidenceBundle, InMemoryArtifactStore, LawEvidenceArtifacts, + LawEvidenceValidationStatus, LawEvidenceValidator, McpResourcePort, RecordingCommandIo, + ReportRenderPort, Timestamp, VersionRegistry, VersionRequirement, WeslawArtifactLocator, }; #[test] @@ -71,6 +72,159 @@ fn evidence_bundle_requires_core_artifacts() { assert_eq!(diagnostic.field_path.as_deref(), Some("artifacts.lawDiff")); } +#[test] +fn evidence_bundle_structure_validation_collects_required_optional_and_duplicate_errors() { + let mut bundle = valid_evidence_bundle(); + bundle.bundle_id = " ".to_owned(); + bundle.artifacts.law_diff.path = "evidence/shared.json".to_owned(); + bundle.artifacts.law_coverage.path = "evidence/shared.json".to_owned(); + bundle.artifacts.law_capabilities.path = "".to_owned(); + bundle.artifacts.policy = Some(ArtifactRef::new(" ")); + + let result = bundle.validate_structure(&VersionRegistry::default()); + + assert_eq!(result.status, LawEvidenceValidationStatus::Invalid); + assert_diagnostic_field(&result.diagnostics, "bundleId"); + assert_diagnostic_field(&result.diagnostics, "artifacts.lawCapabilities"); + assert_diagnostic_field(&result.diagnostics, "artifacts.policy"); + assert_diagnostic_field(&result.diagnostics, "artifacts.lawCoverage"); +} + +#[test] +fn evidence_bundle_provenance_validation_requires_canonical_hashes_and_source() { + let mut bundle = valid_evidence_bundle(); + bundle.provenance.schema_hash = "schema".to_owned(); + bundle.provenance.law_hash = format!("sha256:{}z", "a".repeat(63)); + bundle.provenance.policy_hash = Some(" ".to_owned()); + bundle.provenance.bundle_hash = format!("sha1:{}", "b".repeat(64)); + bundle.provenance.source = "\t".to_owned(); + + let result = bundle.validate_structure(&VersionRegistry::default()); + + assert_eq!(result.status, LawEvidenceValidationStatus::Invalid); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawProvenanceHashMalformed, + ); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawProvenanceHashMissing, + ); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawProvenanceSourceMissing, + ); + assert_diagnostic_field(&result.diagnostics, "provenance.schemaHash"); + assert_diagnostic_field(&result.diagnostics, "provenance.lawHash"); + assert_diagnostic_field(&result.diagnostics, "provenance.policyHash"); + assert_diagnostic_field(&result.diagnostics, "provenance.bundleHash"); + assert_diagnostic_field(&result.diagnostics, "provenance.source"); +} + +#[test] +fn version_fixture_matrix_covers_current_deprecated_malformed_unsupported_and_mixed_artifacts() { + let registry = VersionRegistry::default().with_requirement( + VersionRequirement::new(ArtifactFamily::EvidenceBundle, 1, 1) + .with_deprecated_minor_through(0), + ); + + let deprecated = valid_evidence_bundle(); + let deprecated_result = deprecated.validate_structure(®istry); + assert_eq!( + deprecated_result.status, + LawEvidenceValidationStatus::ValidWithWarnings + ); + assert_diagnostic( + &deprecated_result.diagnostics, + HolmesDiagnosticCode::HlawSchemaVersionDeprecated, + ); + + let mut current = valid_evidence_bundle(); + current.schema_version = "1.1.0".to_owned(); + assert_eq!( + current.validate_structure(®istry).status, + LawEvidenceValidationStatus::Valid + ); + + let mut malformed = valid_evidence_bundle(); + malformed.schema_version = "v1.0.0".to_owned(); + assert_diagnostic( + &malformed.validate_structure(®istry).diagnostics, + HolmesDiagnosticCode::HlawSchemaVersionMalformed, + ); + + let mut unsupported = valid_evidence_bundle(); + unsupported.schema_version = "1.2.0".to_owned(); + assert_diagnostic( + &unsupported.validate_structure(®istry).diagnostics, + HolmesDiagnosticCode::HlawSchemaVersionUnsupportedMinor, + ); + + let mut mixed_generation = valid_evidence_bundle(); + mixed_generation.artifacts.law_diff.schema_version = Some("2.0.0".to_owned()); + let mixed_result = mixed_generation.validate_structure(®istry); + assert_diagnostic( + &mixed_result.diagnostics, + HolmesDiagnosticCode::HlawSchemaVersionUnsupportedMajor, + ); + assert_diagnostic_field(&mixed_result.diagnostics, "artifacts.lawDiff.schemaVersion"); +} + +#[test] +fn law_evidence_validator_reports_artifact_availability_size_and_read_errors() { + let bundle = valid_evidence_bundle(); + let mut store = InMemoryArtifactStore::default(); + store.insert("evidence/law-diff.json", b"oversized".to_vec()); + store.mark_unreadable("evidence/law-capabilities.json", "permission denied"); + store.insert("evidence/bundle-manifest.json", b"manifest".to_vec()); + + let validator = + LawEvidenceValidator::new(WeslawArtifactLocator::new("/workspace")).with_max_bytes(8); + let result = validator.validate(&bundle, &store, &VersionRegistry::default()); + + assert_eq!(result.status, LawEvidenceValidationStatus::Invalid); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawArtifactOversized, + ); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawArtifactUnavailable, + ); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawArtifactUnreadable, + ); + assert_eq!(result.loaded_artifacts.len(), 1); + assert_eq!( + result.loaded_artifacts[0].field_path, + "artifacts.contractBundleManifest" + ); +} + +#[test] +fn first_law_evidence_validation_gate_accepts_clean_bundle_and_artifacts() { + let bundle = valid_evidence_bundle(); + let mut store = InMemoryArtifactStore::default(); + for artifact in [ + &bundle.artifacts.law_diff, + &bundle.artifacts.law_coverage, + &bundle.artifacts.law_capabilities, + &bundle.artifacts.contract_bundle_manifest, + ] { + store.insert(&artifact.path, b"{}".to_vec()); + } + + let validator = + LawEvidenceValidator::new(WeslawArtifactLocator::new("/workspace")).with_max_bytes(1024); + let result = validator.validate(&bundle, &store, &VersionRegistry::default()); + + assert_eq!(result.status, LawEvidenceValidationStatus::Valid); + assert!(result.diagnostics.is_empty()); + assert_eq!(result.loaded_artifacts.len(), 4); + assert_eq!(result.loaded_artifacts[0].field_path, "artifacts.lawDiff"); +} + #[test] fn artifact_locator_normalizes_workspace_relative_paths() { let locator = WeslawArtifactLocator::new("/workspace"); @@ -123,6 +277,10 @@ fn version_registry_accepts_current_versions() { for family in [ ArtifactFamily::EvidenceBundle, + ArtifactFamily::LawDiff, + ArtifactFamily::LawCoverage, + ArtifactFamily::LawCapabilities, + ArtifactFamily::ContractBundleManifest, ArtifactFamily::Policy, ArtifactFamily::Report, ArtifactFamily::AuditWitness, @@ -237,11 +395,17 @@ fn report_renderer_fake_is_deterministic() { } fn evidence_bundle_with_law_diff_path(path: &str) -> HolmesLawEvidenceBundle { + let mut bundle = valid_evidence_bundle(); + bundle.artifacts.law_diff.path = path.to_owned(); + bundle +} + +fn valid_evidence_bundle() -> HolmesLawEvidenceBundle { HolmesLawEvidenceBundle { schema_version: "1.0.0".to_owned(), bundle_id: "bundle-001".to_owned(), artifacts: LawEvidenceArtifacts { - law_diff: ArtifactRef::new(path), + law_diff: ArtifactRef::new("evidence/law-diff.json"), law_coverage: ArtifactRef::new("evidence/law-coverage.json"), law_capabilities: ArtifactRef::new("evidence/law-capabilities.json"), contract_bundle_manifest: ArtifactRef::new("evidence/bundle-manifest.json"), @@ -250,11 +414,27 @@ fn evidence_bundle_with_law_diff_path(path: &str) -> HolmesLawEvidenceBundle { witness: None, }, provenance: BundleProvenance { - schema_hash: "sha256:schema".to_owned(), - law_hash: "sha256:law".to_owned(), + schema_hash: format!("sha256:{}", "a".repeat(64)), + law_hash: format!("sha256:{}", "b".repeat(64)), policy_hash: None, - bundle_hash: "sha256:bundle".to_owned(), + bundle_hash: format!("sha256:{}", "c".repeat(64)), source: "test".to_owned(), }, } } + +fn assert_diagnostic(diagnostics: &[wesley_holmes::HolmesDiagnostic], code: HolmesDiagnosticCode) { + assert!( + diagnostics.iter().any(|diagnostic| diagnostic.code == code), + "expected {code:?} in {diagnostics:#?}" + ); +} + +fn assert_diagnostic_field(diagnostics: &[wesley_holmes::HolmesDiagnostic], field_path: &str) { + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.field_path.as_deref() == Some(field_path)), + "expected field {field_path:?} in {diagnostics:#?}" + ); +} diff --git a/docs/BEARING.md b/docs/BEARING.md index a08b4e73..145e2743 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -446,17 +446,20 @@ without pinning Wesley to legacy Node. The `0019` packet names the semantic law architecture that lets Wesley compile meaning alongside shape without smuggling runtime ownership into the base compiler. -The implementation budget is **90 slices**. Status: **10 / 90 slices closed**. -Closed implementation slices now cover `HIMP-001` through `HIMP-010`: the +The implementation budget is **90 slices**. Status: **15 / 90 slices closed**. +Closed implementation slices now cover `HIMP-001` through `HIMP-015`: the workspace-local Rust Holmes assurance crate shell, domain dependency-boundary tests, deterministic port traits and fakes, the first diagnostic taxonomy, the workspace preflight hook, implementation-boundary docs, the typed `HolmesLawEvidenceBundle`, safe artifact path normalization, accepted -artifact-family version registry, and schema-version diagnostics. - -The first implementation PR should take `HIMP-001` through `HIMP-015`, because -those slices establish the Rust Holmes assurance shell, evidence bundle, -artifact locator, schema-version validation, and first ingest ports before any +artifact-family version registry, schema-version diagnostics, collected bundle +structure validation, canonical provenance validation, artifact availability and +size diagnostics, versioning fixture coverage, and the first local evidence +validation gate. + +The next implementation PR should take `HIMP-016` through `HIMP-025`, because +those slices ingest Wesley-published law diff, coverage, capability, and +manifest artifacts into typed Holmes findings and gate decisions before any publisher or branch-protection surface exists. Every implementation slice below references the completed `0020` PRD/test-plan From 69391b2a5b06c967040759bfaa7fbfbfb29543e4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 19:13:49 -0700 Subject: [PATCH 2/2] fix(holmes): harden law evidence validation --- CHANGELOG.md | 6 ++ .../src/application/evidence_validation.rs | 29 ++++- crates/wesley-holmes/src/domain/diagnostic.rs | 4 + crates/wesley-holmes/src/domain/evidence.rs | 38 ++++++- crates/wesley-holmes/tests/foundation.rs | 100 ++++++++++++++++++ 5 files changed, 174 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67991dc8..9855c676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ### Fixed +- **Rust Holmes validation gate review fixes**: Law evidence validation now + continues artifact checks when structure validation emits warning-only + diagnostics, rejects duplicate artifact roles after workspace-relative path + normalization, and validates artifact `sha256` digests with artifact-specific + diagnostics instead of allowing malformed digest anchors into later + traceability gates. - **Rust Holmes assurance review fixes**: The new Holmes artifact locator now returns stable Holmes diagnostics for invalid and escaping paths, rejects platform-specific backslash and drive-path input before normalization, the diff --git a/crates/wesley-holmes/src/application/evidence_validation.rs b/crates/wesley-holmes/src/application/evidence_validation.rs index a568b13a..ca219f72 100644 --- a/crates/wesley-holmes/src/application/evidence_validation.rs +++ b/crates/wesley-holmes/src/application/evidence_validation.rs @@ -1,5 +1,7 @@ //! Evidence-bundle validation application service. +use std::collections::BTreeMap; + use crate::application::WeslawArtifactLocator; use crate::domain::{ ArtifactRef, HolmesDiagnostic, HolmesDiagnosticCode, HolmesLawEvidenceBundle, HolmesSeverity, @@ -37,12 +39,17 @@ impl LawEvidenceValidator { version_registry: &VersionRegistry, ) -> LawEvidenceValidationResult { let structural = bundle.validate_structure(version_registry); - if !structural.diagnostics.is_empty() { + if structural + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == HolmesSeverity::Error) + { return structural; } - let mut diagnostics = Vec::new(); + let mut diagnostics = structural.diagnostics; let mut loaded_artifacts = Vec::new(); + let mut normalized_paths = BTreeMap::new(); for bundle_artifact in bundle.artifact_refs() { let artifact = bundle_artifact.artifact; @@ -54,6 +61,24 @@ impl LawEvidenceValidator { } }; + if let Some(first_field_path) = normalized_paths.insert( + resolved.workspace_relative.clone(), + bundle_artifact.field_path, + ) { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawEvidenceBundleInvalid, + HolmesSeverity::Error, + format!( + "artifact path normalizes to the same path as {first_field_path}; each artifact role must point at distinct evidence" + ), + ) + .for_family(bundle_artifact.family.id()) + .at_field(bundle_artifact.field_path), + ); + continue; + } + let normalized = ArtifactRef { path: resolved.workspace_relative.clone(), schema_version: artifact.schema_version.clone(), diff --git a/crates/wesley-holmes/src/domain/diagnostic.rs b/crates/wesley-holmes/src/domain/diagnostic.rs index b396e4bc..b991208c 100644 --- a/crates/wesley-holmes/src/domain/diagnostic.rs +++ b/crates/wesley-holmes/src/domain/diagnostic.rs @@ -27,6 +27,10 @@ pub enum HolmesDiagnosticCode { HlawArtifactPathInvalid, /// A law evidence bundle was missing a required artifact reference. HlawEvidenceBundleInvalid, + /// An artifact digest field was absent or blank. + HlawArtifactHashMissing, + /// An artifact digest field did not use canonical `sha256:<64 lowercase hex>` syntax. + HlawArtifactHashMalformed, /// A provenance hash was absent or blank. HlawProvenanceHashMissing, /// A provenance hash did not use canonical `sha256:<64 lowercase hex>` syntax. diff --git a/crates/wesley-holmes/src/domain/evidence.rs b/crates/wesley-holmes/src/domain/evidence.rs index b151bc21..255a06ad 100644 --- a/crates/wesley-holmes/src/domain/evidence.rs +++ b/crates/wesley-holmes/src/domain/evidence.rs @@ -347,6 +347,14 @@ impl HolmesLawEvidenceBundle { Err(diagnostic) => diagnostics.push(diagnostic.at_field(schema_field)), } } + + if let Some(sha256) = artifact.sha256.as_deref() { + validate_artifact_sha256( + sha256, + format!("{}.sha256", bundle_artifact.field_path), + diagnostics, + ); + } } } @@ -397,9 +405,10 @@ fn validation_status_for(diagnostics: &[HolmesDiagnostic]) -> LawEvidenceValidat fn validate_required_sha256( value: &str, - field_path: &'static str, + field_path: impl Into, diagnostics: &mut Vec, ) { + let field_path = field_path.into(); if value.trim().is_empty() { diagnostics.push( HolmesDiagnostic::new( @@ -421,6 +430,33 @@ fn validate_required_sha256( } } +fn validate_artifact_sha256( + value: &str, + field_path: impl Into, + diagnostics: &mut Vec, +) { + let field_path = field_path.into(); + if value.trim().is_empty() { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawArtifactHashMissing, + HolmesSeverity::Error, + "artifact sha256 digest must not be blank", + ) + .at_field(field_path), + ); + } else if !is_canonical_sha256(value) { + diagnostics.push( + HolmesDiagnostic::new( + HolmesDiagnosticCode::HlawArtifactHashMalformed, + HolmesSeverity::Error, + "artifact sha256 digest must use sha256:<64 lowercase hex>", + ) + .at_field(field_path), + ); + } +} + fn is_canonical_sha256(value: &str) -> bool { let Some(hex) = value.strip_prefix("sha256:") else { return false; diff --git a/crates/wesley-holmes/tests/foundation.rs b/crates/wesley-holmes/tests/foundation.rs index 4ba58843..2fd588fc 100644 --- a/crates/wesley-holmes/tests/foundation.rs +++ b/crates/wesley-holmes/tests/foundation.rs @@ -170,6 +170,59 @@ fn version_fixture_matrix_covers_current_deprecated_malformed_unsupported_and_mi assert_diagnostic_field(&mixed_result.diagnostics, "artifacts.lawDiff.schemaVersion"); } +#[test] +fn artifact_sha256_fields_must_be_canonical_when_present() { + let mut bundle = valid_evidence_bundle(); + + for (sha256, field_path) in [ + (" ", "artifacts.lawDiff.sha256"), + ( + "sha1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts.lawCoverage.sha256", + ), + ( + "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "artifacts.lawCapabilities.sha256", + ), + ("sha256:aaaaaaaa", "artifacts.contractBundleManifest.sha256"), + ] { + match field_path { + "artifacts.lawDiff.sha256" => { + bundle.artifacts.law_diff.sha256 = Some(sha256.to_owned()) + } + "artifacts.lawCoverage.sha256" => { + bundle.artifacts.law_coverage.sha256 = Some(sha256.to_owned()); + } + "artifacts.lawCapabilities.sha256" => { + bundle.artifacts.law_capabilities.sha256 = Some(sha256.to_owned()); + } + "artifacts.contractBundleManifest.sha256" => { + bundle.artifacts.contract_bundle_manifest.sha256 = Some(sha256.to_owned()); + } + _ => unreachable!("test fixture uses known fields"), + } + } + + let result = bundle.validate_structure(&VersionRegistry::default()); + + assert_eq!(result.status, LawEvidenceValidationStatus::Invalid); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawArtifactHashMissing, + ); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawArtifactHashMalformed, + ); + assert_diagnostic_field(&result.diagnostics, "artifacts.lawDiff.sha256"); + assert_diagnostic_field(&result.diagnostics, "artifacts.lawCoverage.sha256"); + assert_diagnostic_field(&result.diagnostics, "artifacts.lawCapabilities.sha256"); + assert_diagnostic_field( + &result.diagnostics, + "artifacts.contractBundleManifest.sha256", + ); +} + #[test] fn law_evidence_validator_reports_artifact_availability_size_and_read_errors() { let bundle = valid_evidence_bundle(); @@ -202,6 +255,53 @@ fn law_evidence_validator_reports_artifact_availability_size_and_read_errors() { ); } +#[test] +fn law_evidence_validator_continues_after_structure_warnings_to_find_artifact_errors() { + let bundle = valid_evidence_bundle(); + let registry = VersionRegistry::default().with_requirement( + VersionRequirement::new(ArtifactFamily::EvidenceBundle, 1, 1) + .with_deprecated_minor_through(0), + ); + let store = InMemoryArtifactStore::default(); + + let result = LawEvidenceValidator::new(WeslawArtifactLocator::new("/workspace")) + .validate(&bundle, &store, ®istry); + + assert_eq!(result.status, LawEvidenceValidationStatus::Invalid); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawSchemaVersionDeprecated, + ); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawArtifactUnavailable, + ); +} + +#[test] +fn law_evidence_validator_rejects_duplicate_normalized_artifact_paths() { + let mut bundle = valid_evidence_bundle(); + bundle.artifacts.law_diff.path = "./evidence/shared.json".to_owned(); + bundle.artifacts.law_coverage.path = "evidence/shared.json".to_owned(); + let mut store = InMemoryArtifactStore::default(); + store.insert("evidence/shared.json", b"shared".to_vec()); + store.insert("evidence/law-capabilities.json", b"capabilities".to_vec()); + store.insert("evidence/bundle-manifest.json", b"manifest".to_vec()); + + let result = LawEvidenceValidator::new(WeslawArtifactLocator::new("/workspace")).validate( + &bundle, + &store, + &VersionRegistry::default(), + ); + + assert_eq!(result.status, LawEvidenceValidationStatus::Invalid); + assert_diagnostic_field(&result.diagnostics, "artifacts.lawCoverage"); + assert_diagnostic( + &result.diagnostics, + HolmesDiagnosticCode::HlawEvidenceBundleInvalid, + ); +} + #[test] fn first_law_evidence_validation_gate_accepts_clean_bundle_and_artifacts() { let bundle = valid_evidence_bundle();