From f8215341f62a8229014b091506e1a8735f6efcaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:00:47 +0900 Subject: [PATCH 01/72] test(reputation): add contract crate workspace --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b2ec231..4692dde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ description = "Rust-first WAF/IDS/AI SOC gateway with DNSBL and commercial readi license = "MIT" [workspace] -members = [".", "crates/waf-ids-core"] +members = [".", "crates/waf-ids-core", "crates/wardnet-reputation-core"] resolver = "3" [dependencies] From 46818724c6ae059c7f5733470b9126b53745f206 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:00:53 +0900 Subject: [PATCH 02/72] test(reputation): scaffold contract crate --- crates/wardnet-reputation-core/Cargo.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 crates/wardnet-reputation-core/Cargo.toml diff --git a/crates/wardnet-reputation-core/Cargo.toml b/crates/wardnet-reputation-core/Cargo.toml new file mode 100644 index 0000000..7270b88 --- /dev/null +++ b/crates/wardnet-reputation-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wardnet-reputation-core" +version = "0.1.0" +edition = "2024" +description = "Pure Wardnet outbound site-reputation domain contracts and policy core" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" From 7b3b297b827e1b2d1d33d27951dee48de82325a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:00:59 +0900 Subject: [PATCH 03/72] test(reputation): expose contract model module --- crates/wardnet-reputation-core/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 crates/wardnet-reputation-core/src/lib.rs diff --git a/crates/wardnet-reputation-core/src/lib.rs b/crates/wardnet-reputation-core/src/lib.rs new file mode 100644 index 0000000..2076a83 --- /dev/null +++ b/crates/wardnet-reputation-core/src/lib.rs @@ -0,0 +1,9 @@ +//! Pure Wardnet outbound site-reputation contracts. +//! +//! This crate deliberately performs no HTTP, DNS, transport authorization, database I/O, +//! environment access, or LLM work. Executable outbound target interpretation remains an +//! EgressWeave responsibility; this crate only accepts already-canonical offline descriptors. + +pub mod model; + +pub use model::*; From 4cf6e93e864dc33aae4a9e1724ba557f137d3d99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:01:38 +0900 Subject: [PATCH 04/72] test(reputation): add deliberately permissive v1 contract stub --- crates/wardnet-reputation-core/src/model.rs | 312 ++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 crates/wardnet-reputation-core/src/model.rs diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs new file mode 100644 index 0000000..2b31229 --- /dev/null +++ b/crates/wardnet-reputation-core/src/model.rs @@ -0,0 +1,312 @@ +//! Versioned, transport-neutral outbound site-reputation domain contracts. + +use serde::{Deserialize, Serialize}; + +/// Wire schema identifier for the first Wardnet reputation contract family. +pub const REPUTATION_SCHEMA_V1: &str = "wardnet.reputation.v1"; + +/// Direction of the evaluated destination operation. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DirectionV1 { + /// Outbound traffic from a protected workload toward an external destination. + Outbound, + /// Inbound traffic is represented so validation can reject it explicitly. + Inbound, +} + +/// Canonical subject kind supplied to Wardnet by an owning canonicalization boundary. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum DestinationSubjectKindV1 { + /// Exact canonical host name. + ExactHost, + /// Exact visible canonical URL observable. + ObservableUrl, + /// Exact canonical actual address and port observable. + ActualAddress, +} + +/// Explicit matching scope for a canonical destination subject. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DestinationScopeV1 { + /// Match exactly the supplied canonical subject. + Exact, + /// Match the exact host and dot-boundary subdomains only. + HostAndSubdomains, +} + +/// Canonical destination descriptor that Wardnet matches without reparsing network syntax. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DestinationSubjectV1 { + /// Subject kind defining how the opaque canonical value may be matched. + pub kind: DestinationSubjectKindV1, + /// Canonical value produced by the owning canonicalization boundary. + pub value: String, + /// Explicit matching scope; non-host kinds must remain exact. + pub scope: DestinationScopeV1, +} + +/// Authenticated evaluation context after identity claims have been verified by the service edge. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DestinationContextV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Evaluated direction; this contract accepts outbound only. + pub direction: DirectionV1, + /// Authenticated tenant identifier. + pub tenant_id: String, + /// Authenticated workload identifier. + pub workload_id: String, + /// Registered purpose identifier. + pub purpose: String, + /// Unique operation correlation identifier. + pub operation_id: String, + /// Reputation protection profile identifier. + pub profile_id: String, + /// Canonical destination subject. + pub subject: DestinationSubjectV1, + /// Canonicalization profile name owned outside this crate. + pub canonicalization_profile: String, + /// Canonicalization profile version owned outside this crate. + pub canonicalization_version: String, +} + +impl DestinationContextV1 { + /// Validate the bounded contract shape without authenticating caller-controlled identity text. + pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + Ok(()) + } +} + +/// Security classification preserved from an eligible producer record. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceClassificationV1 { + /// Producer evidence asserts a known malicious destination within its explicit scope. + KnownMalicious, + /// Producer evidence is adverse but not eligible to assert known maliciousness. + Suspicious, +} + +/// Versioned source evidence retained with lifecycle and provenance semantics. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EvidenceRecordV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Reviewed source policy identifier. + pub source_id: String, + /// Producer-native record identifier. + pub producer_record_id: String, + /// Producer-native monotonic or immutable record version identifier. + pub producer_record_version: String, + /// Canonical subject and explicit scope asserted by the producer mapping. + pub subject: DestinationSubjectV1, + /// Security classification preserved by the adapter. + pub classification: EvidenceClassificationV1, + /// Producer severity text, preserved rather than converted into a probability. + pub producer_severity: Option, + /// Optional producer confidence on the producer's own 0-100 scale. + pub producer_confidence: Option, + /// Time the producer says the observation was made. + pub observed_at_unix: u64, + /// Time Wardnet received the authenticated producer record. + pub received_at_unix: u64, + /// Start of the producer validity interval. + pub valid_from_unix: u64, + /// End of the producer validity interval. + pub valid_until_unix: u64, + /// Producer lifecycle revocation flag. + pub revoked: bool, + /// Producer lifecycle deletion flag. + pub deleted: bool, + /// Whether reviewed source policy permits this record to contribute to enforcement. + pub enforcement_eligible: bool, + /// Optional tenant restriction; absence means source policy may treat the evidence as global. + pub tenant_id: Option, + /// Optional producer data-marking identifier. + pub marking: Option, + /// Licensing or terms reference required for evidence use and redistribution decisions. + pub license_ref: String, + /// Bounded provenance references that identify authenticated source material or derivation. + pub provenance_refs: Vec, +} + +impl EvidenceRecordV1 { + /// Validate contract shape and time ordering at an injected evaluation time. + pub fn validate_at(&self, _now_unix: u64) -> Result<(), ContractValidationErrorV1> { + Ok(()) + } +} + +/// Policy attached to one reviewed evidence source. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SourcePolicyV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Stable source identifier referenced by evidence and policy snapshots. + pub source_id: String, + /// Whether this source can establish known-malicious enforcement evidence. + pub enforcement_capable: bool, + /// Canonical subject kinds the source is permitted to assert. + pub permitted_subject_kinds: Vec, + /// Maximum evidence age allowed by Wardnet policy, in seconds. + pub max_evidence_age_seconds: u64, + /// Purposes for which this source may contribute; empty is invalid. + pub allowed_purposes: Vec, +} + +impl SourcePolicyV1 { + /// Validate source-policy shape without fetching or authenticating a source. + pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + Ok(()) + } +} + +/// Runtime policy mode; monitor never manufactures a protect authorization. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvaluationModeV1 { + /// Enforce fail-closed reputation policy. + Protect, + /// Produce a shadow result without a protect grant. + Monitor, +} + +/// Immutable reputation policy revision consumed by the pure core. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PolicySnapshotV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Stable policy identifier. + pub policy_id: String, + /// Monotonic immutable policy revision. + pub revision: u64, + /// Evaluation mode. + pub mode: EvaluationModeV1, + /// Nonempty reviewed sources required for protect evaluation. + pub required_sources: Vec, + /// Policy validity start. + pub valid_from_unix: u64, + /// Policy validity end. + pub valid_until_unix: u64, +} + +impl PolicySnapshotV1 { + /// Validate the policy snapshot at an injected evaluation time. + pub fn validate_at(&self, _now_unix: u64) -> Result<(), ContractValidationErrorV1> { + Ok(()) + } +} + +/// Deterministic assessment dimension kept separate from policy action. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReputationAssessmentV1 { + /// At least one active eligible reviewed source asserts known maliciousness. + KnownMalicious, + /// Adverse evidence exists but does not establish known maliciousness. + Suspicious, + /// No active eligible adverse match establishes safety. + Unknown, +} + +/// Health of the evidence authorities required by the evaluated policy. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceHealthV1 { + /// All required authorities and applicable evidence are current. + Fresh, + /// Optional authority degradation exists while all required authorities remain healthy. + Degraded, + /// Required evidence exists but is outside its validity or age bound. + Expired, + /// A required authority or its verifiable evidence is unavailable. + Unavailable, +} + +/// Reputation policy action; transport authorization remains a separate authority. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PolicyActionV1 { + /// Reputation policy allows continuation to other independent gates. + Allow, + /// Reputation policy denies continuation. + Deny, +} + +/// Stable machine-readable reason for a reputation policy action. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DecisionReasonV1 { + /// An eligible hard-threat record matched the exact evaluated scope. + KnownMalicious, + /// Suspicious evidence caused the initial protect profile to deny. + Suspicious, + /// No adverse match exists, but the destination remains unknown without authorization. + UnknownDestination, + /// Required evidence or authority is unavailable or unverifiable. + RequiredAuthorityUnavailable, + /// Contract identity or version could not be validated. + InvalidContract, +} + +/// Explainable pure-core decision envelope; it is not proof that traffic was actually blocked. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DecisionEnvelopeV1 { + /// Contract schema identifier. + pub schema_version: String, + /// Unique evaluation identifier. + pub evaluation_id: String, + /// Policy identifier used for the decision. + pub policy_id: String, + /// Exact policy revision used for the decision. + pub policy_revision: u64, + /// Deterministic security assessment. + pub assessment: ReputationAssessmentV1, + /// Required evidence health. + pub evidence_health: EvidenceHealthV1, + /// Reputation-only policy action. + pub action: PolicyActionV1, + /// Stable explanation reason. + pub reason: DecisionReasonV1, + /// Evaluation time. + pub evaluated_at_unix: u64, + /// Earliest expiry across policy and evidence used by the decision. + pub expires_at_unix: u64, + /// Bounded producer evidence references used for explanation. + pub evidence_refs: Vec, +} + +impl DecisionEnvelopeV1 { + /// Validate a serialized decision envelope without treating it as an authenticated grant. + pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + Ok(()) + } +} + +/// Typed fail-closed validation errors for versioned reputation contracts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContractValidationErrorV1 { + /// Schema identifier is unsupported by this crate version. + UnsupportedSchema, + /// Direction is not outbound. + WrongDirection, + /// A required bounded text field is blank. + BlankField(&'static str), + /// A bounded text or list field exceeds the v1 contract limit. + BoundExceeded(&'static str), + /// A destination kind/scope combination is ambiguous or unsupported. + AmbiguousSubjectScope, + /// A time interval or observed/received ordering is invalid. + InvalidTimeOrder, + /// A required-source policy contains no required sources. + EmptyRequiredSources, + /// A required-source identifier appears more than once. + DuplicateRequiredSource, + /// A producer confidence value exceeds the preserved 0-100 range. + InvalidConfidence, + /// A source policy does not permit any subject kind or purpose. + EmptySourceEligibility, +} From 69596893b71d823c45be50fcbab0552fe5322e79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:01:48 +0900 Subject: [PATCH 05/72] test(reputation): add synthetic exact-host contract fixture --- .../reputation/v1/exact_host_roundtrip.json | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/fixtures/reputation/v1/exact_host_roundtrip.json diff --git a/tests/fixtures/reputation/v1/exact_host_roundtrip.json b/tests/fixtures/reputation/v1/exact_host_roundtrip.json new file mode 100644 index 0000000..f93c419 --- /dev/null +++ b/tests/fixtures/reputation/v1/exact_host_roundtrip.json @@ -0,0 +1,33 @@ +{ + "case_id": "REP-CONTRACT-ROUNDTRIP-01", + "now_unix": 1788652800, + "context": { + "schema_version": "wardnet.reputation.v1", + "direction": "outbound", + "tenant_id": "tenant-example", + "workload_id": "workload-example", + "purpose": "package_metadata", + "operation_id": "op-0001", + "profile_id": "protect-default", + "subject": { + "kind": "exact_host", + "value": "updates.example.invalid", + "scope": "exact" + }, + "canonicalization_profile": "egressweave-offline-fixture", + "canonicalization_version": "1" + }, + "source_policy": { + "schema_version": "wardnet.reputation.v1", + "source_id": "reviewed-source", + "enforcement_capable": true, + "permitted_subject_kinds": ["exact_host"], + "max_evidence_age_seconds": 3600, + "allowed_purposes": ["package_metadata"] + }, + "evidence": [], + "expected_assessment": "unknown", + "expected_action": "deny", + "expected_reason": "unknown_destination", + "expected_visibility": "exact_host" +} From 1eff7d2fe8e2829039cbe3358710940eb6b97f3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:02:10 +0900 Subject: [PATCH 06/72] test(reputation): add hostile v1 contract RED --- .../wardnet-reputation-core/tests/contract.rs | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 crates/wardnet-reputation-core/tests/contract.rs diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs new file mode 100644 index 0000000..9731963 --- /dev/null +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -0,0 +1,197 @@ +use serde::Deserialize; +use wardnet_reputation_core::{ + ContractValidationErrorV1, DestinationContextV1, DestinationScopeV1, DestinationSubjectKindV1, + DestinationSubjectV1, DirectionV1, EvidenceClassificationV1, EvidenceRecordV1, + EvaluationModeV1, PolicySnapshotV1, SourcePolicyV1, REPUTATION_SCHEMA_V1, +}; + +const NOW: u64 = 1_788_652_800; + +fn subject() -> DestinationSubjectV1 { + DestinationSubjectV1 { + kind: DestinationSubjectKindV1::ExactHost, + value: "updates.example.invalid".to_string(), + scope: DestinationScopeV1::Exact, + } +} + +fn context() -> DestinationContextV1 { + DestinationContextV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + direction: DirectionV1::Outbound, + tenant_id: "tenant-example".to_string(), + workload_id: "workload-example".to_string(), + purpose: "package_metadata".to_string(), + operation_id: "op-0001".to_string(), + profile_id: "protect-default".to_string(), + subject: subject(), + canonicalization_profile: "egressweave-offline-fixture".to_string(), + canonicalization_version: "1".to_string(), + } +} + +fn source_policy() -> SourcePolicyV1 { + SourcePolicyV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + source_id: "reviewed-source".to_string(), + enforcement_capable: true, + permitted_subject_kinds: vec![DestinationSubjectKindV1::ExactHost], + max_evidence_age_seconds: 3_600, + allowed_purposes: vec!["package_metadata".to_string()], + } +} + +fn evidence() -> EvidenceRecordV1 { + EvidenceRecordV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + source_id: "reviewed-source".to_string(), + producer_record_id: "record-1".to_string(), + producer_record_version: "1".to_string(), + subject: subject(), + classification: EvidenceClassificationV1::KnownMalicious, + producer_severity: Some("high".to_string()), + producer_confidence: Some(80), + observed_at_unix: NOW - 120, + received_at_unix: NOW - 60, + valid_from_unix: NOW - 120, + valid_until_unix: NOW + 600, + revoked: false, + deleted: false, + enforcement_eligible: true, + tenant_id: Some("tenant-example".to_string()), + marking: Some("TLP:CLEAR".to_string()), + license_ref: "synthetic-fixture".to_string(), + provenance_refs: vec!["urn:wardnet:test:record-1".to_string()], + } +} + +#[test] +fn rejects_wrong_direction_and_unknown_schema() { + let mut candidate = context(); + candidate.direction = DirectionV1::Inbound; + assert_eq!(candidate.validate(), Err(ContractValidationErrorV1::WrongDirection)); + + let mut candidate = context(); + candidate.schema_version = "wardnet.reputation.v2".to_string(); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::UnsupportedSchema) + ); +} + +#[test] +fn rejects_blank_authenticated_context_fields() { + for field in ["workload_id", "purpose"] { + let mut candidate = context(); + match field { + "workload_id" => candidate.workload_id = " \t".to_string(), + "purpose" => candidate.purpose.clear(), + _ => unreachable!(), + } + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::BlankField(field)) + ); + } +} + +#[test] +fn rejects_ambiguous_subject_scope() { + let mut candidate = context(); + candidate.subject.kind = DestinationSubjectKindV1::ObservableUrl; + candidate.subject.scope = DestinationScopeV1::HostAndSubdomains; + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::AmbiguousSubjectScope) + ); +} + +#[test] +fn rejects_missing_required_source_policy() { + let policy = PolicySnapshotV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + policy_id: "protect-default".to_string(), + revision: 1, + mode: EvaluationModeV1::Protect, + required_sources: Vec::new(), + valid_from_unix: NOW - 60, + valid_until_unix: NOW + 600, + }; + assert_eq!( + policy.validate_at(NOW), + Err(ContractValidationErrorV1::EmptyRequiredSources) + ); +} + +#[test] +fn rejects_invalid_evidence_time_order_and_confidence() { + let mut candidate = evidence(); + candidate.valid_from_unix = NOW + 10; + candidate.valid_until_unix = NOW; + assert_eq!( + candidate.validate_at(NOW), + Err(ContractValidationErrorV1::InvalidTimeOrder) + ); + + let mut candidate = evidence(); + candidate.producer_confidence = Some(101); + assert_eq!( + candidate.validate_at(NOW), + Err(ContractValidationErrorV1::InvalidConfidence) + ); +} + +#[test] +fn rejects_empty_source_eligibility() { + let mut candidate = source_policy(); + candidate.permitted_subject_kinds.clear(); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::EmptySourceEligibility) + ); + + let mut candidate = source_policy(); + candidate.allowed_purposes.clear(); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::EmptySourceEligibility) + ); +} + +#[derive(Debug, Deserialize)] +struct ContractFixture { + case_id: String, + now_unix: u64, + context: DestinationContextV1, + source_policy: SourcePolicyV1, + evidence: Vec, + expected_assessment: String, + expected_action: String, + expected_reason: String, + expected_visibility: String, +} + +#[test] +fn exact_host_fixture_round_trips_stably() { + let fixture: ContractFixture = serde_json::from_str(include_str!( + "../../../tests/fixtures/reputation/v1/exact_host_roundtrip.json" + )) + .expect("synthetic contract fixture is valid JSON"); + + assert_eq!(fixture.case_id, "REP-CONTRACT-ROUNDTRIP-01"); + assert_eq!(fixture.now_unix, NOW); + assert_eq!(fixture.expected_assessment, "unknown"); + assert_eq!(fixture.expected_action, "deny"); + assert_eq!(fixture.expected_reason, "unknown_destination"); + assert_eq!(fixture.expected_visibility, "exact_host"); + assert!(fixture.evidence.is_empty()); + fixture.context.validate().expect("fixture context is valid"); + fixture + .source_policy + .validate() + .expect("fixture source policy is valid"); + + let encoded = serde_json::to_string(&fixture.context).expect("context serializes"); + let decoded: DestinationContextV1 = serde_json::from_str(&encoded).expect("context deserializes"); + assert_eq!(decoded, fixture.context); +} From 21c2b9c2a9be5a4a1a9bfa17e25725ac6075c37d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:07:08 +0900 Subject: [PATCH 07/72] test(reputation): lock contract workspace --- Cargo.lock | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c696190..3b59279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" name = "cc" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "5add81bb678e6cb321aff7a9ab82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex", @@ -1168,7 +1168,7 @@ dependencies = [ name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "1f3ccbac311fea05f86f61904f462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" @@ -1332,7 +1332,7 @@ dependencies = [ name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "b6c140620e7fe1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "waf-ids-ai-soc" @@ -1377,6 +1377,14 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wardnet-reputation-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" From b9121824ccf8d49f9c88318ce2fe34c952b5b3eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:07:58 +0900 Subject: [PATCH 08/72] fix(reputation): restore generated lock integrity --- Cargo.lock | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b59279..c696190 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" name = "cc" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7a9ab82b112dbc032cea19f91d6b8e3582b9" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex", @@ -1168,7 +1168,7 @@ dependencies = [ name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904f462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" @@ -1332,7 +1332,7 @@ dependencies = [ name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7fe1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "waf-ids-ai-soc" @@ -1377,14 +1377,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wardnet-reputation-core" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" From d8c91656715fbf8115d9d8d98921463f7ea7baaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:08:49 +0900 Subject: [PATCH 09/72] chore(reputation): bootstrap generated lock artifact --- .../workflows/reputation-lock-bootstrap.yml | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/reputation-lock-bootstrap.yml diff --git a/.github/workflows/reputation-lock-bootstrap.yml b/.github/workflows/reputation-lock-bootstrap.yml new file mode 100644 index 0000000..1b18ce5 --- /dev/null +++ b/.github/workflows/reputation-lock-bootstrap.yml @@ -0,0 +1,28 @@ +name: Reputation lock bootstrap + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: wardnet-reputation-lock-${{ github.repository }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + generate-lock: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Regenerate lockfile for the new workspace member + run: cargo generate-lockfile + - name: Upload generated lockfile + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: reputation-generated-lock + path: Cargo.lock + if-no-files-found: error From 00cb63e60a91f58d9807d067d5f377546e6f425e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 15:03:49 +0900 Subject: [PATCH 10/72] ci(reputation): materialize formatted bootstrap output --- .github/workflows/reputation-lock-bootstrap.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reputation-lock-bootstrap.yml b/.github/workflows/reputation-lock-bootstrap.yml index 1b18ce5..a1d443a 100644 --- a/.github/workflows/reputation-lock-bootstrap.yml +++ b/.github/workflows/reputation-lock-bootstrap.yml @@ -20,9 +20,15 @@ jobs: toolchain: stable - name: Regenerate lockfile for the new workspace member run: cargo generate-lockfile - - name: Upload generated lockfile + - name: Format the new Rust contract slice + run: cargo fmt --all + - name: Upload generated bootstrap output uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: reputation-generated-lock - path: Cargo.lock + name: reputation-bootstrap-output + path: | + Cargo.lock + crates/wardnet-reputation-core/src/lib.rs + crates/wardnet-reputation-core/src/model.rs + crates/wardnet-reputation-core/tests/contract.rs if-no-files-found: error From c1c8c52000689128fe3c2cfb1300d58f86c6cd82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 15:07:43 +0900 Subject: [PATCH 11/72] ci(reputation): preserve existing dependency lock --- .github/workflows/reputation-lock-bootstrap.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reputation-lock-bootstrap.yml b/.github/workflows/reputation-lock-bootstrap.yml index a1d443a..dbb71a3 100644 --- a/.github/workflows/reputation-lock-bootstrap.yml +++ b/.github/workflows/reputation-lock-bootstrap.yml @@ -18,8 +18,8 @@ jobs: - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable - - name: Regenerate lockfile for the new workspace member - run: cargo generate-lockfile + - name: Add the workspace package without dependency upgrades + run: cargo check -p wardnet-reputation-core - name: Format the new Rust contract slice run: cargo fmt --all - name: Upload generated bootstrap output From 48dff588a7e4db804cc225321b9231fc3aec9a55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 15:24:49 +0900 Subject: [PATCH 12/72] test(reputation): reach semantic contract RED --- .../workflows/reputation-lock-bootstrap.yml | 34 ------------------- Cargo.lock | 8 +++++ .../wardnet-reputation-core/tests/contract.rs | 17 +++++++--- 3 files changed, 20 insertions(+), 39 deletions(-) delete mode 100644 .github/workflows/reputation-lock-bootstrap.yml diff --git a/.github/workflows/reputation-lock-bootstrap.yml b/.github/workflows/reputation-lock-bootstrap.yml deleted file mode 100644 index dbb71a3..0000000 --- a/.github/workflows/reputation-lock-bootstrap.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Reputation lock bootstrap - -on: - pull_request: - -permissions: - contents: read - -concurrency: - group: wardnet-reputation-lock-${{ github.repository }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - generate-lock: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Add the workspace package without dependency upgrades - run: cargo check -p wardnet-reputation-core - - name: Format the new Rust contract slice - run: cargo fmt --all - - name: Upload generated bootstrap output - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: reputation-bootstrap-output - path: | - Cargo.lock - crates/wardnet-reputation-core/src/lib.rs - crates/wardnet-reputation-core/src/model.rs - crates/wardnet-reputation-core/tests/contract.rs - if-no-files-found: error diff --git a/Cargo.lock b/Cargo.lock index c696190..1e2824a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1377,6 +1377,14 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wardnet-reputation-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs index 9731963..2e241f9 100644 --- a/crates/wardnet-reputation-core/tests/contract.rs +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -1,8 +1,8 @@ use serde::Deserialize; use wardnet_reputation_core::{ ContractValidationErrorV1, DestinationContextV1, DestinationScopeV1, DestinationSubjectKindV1, - DestinationSubjectV1, DirectionV1, EvidenceClassificationV1, EvidenceRecordV1, - EvaluationModeV1, PolicySnapshotV1, SourcePolicyV1, REPUTATION_SCHEMA_V1, + DestinationSubjectV1, DirectionV1, EvaluationModeV1, EvidenceClassificationV1, + EvidenceRecordV1, PolicySnapshotV1, REPUTATION_SCHEMA_V1, SourcePolicyV1, }; const NOW: u64 = 1_788_652_800; @@ -69,7 +69,10 @@ fn evidence() -> EvidenceRecordV1 { fn rejects_wrong_direction_and_unknown_schema() { let mut candidate = context(); candidate.direction = DirectionV1::Inbound; - assert_eq!(candidate.validate(), Err(ContractValidationErrorV1::WrongDirection)); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::WrongDirection) + ); let mut candidate = context(); candidate.schema_version = "wardnet.reputation.v2".to_string(); @@ -185,13 +188,17 @@ fn exact_host_fixture_round_trips_stably() { assert_eq!(fixture.expected_reason, "unknown_destination"); assert_eq!(fixture.expected_visibility, "exact_host"); assert!(fixture.evidence.is_empty()); - fixture.context.validate().expect("fixture context is valid"); + fixture + .context + .validate() + .expect("fixture context is valid"); fixture .source_policy .validate() .expect("fixture source policy is valid"); let encoded = serde_json::to_string(&fixture.context).expect("context serializes"); - let decoded: DestinationContextV1 = serde_json::from_str(&encoded).expect("context deserializes"); + let decoded: DestinationContextV1 = + serde_json::from_str(&encoded).expect("context deserializes"); assert_eq!(decoded, fixture.context); } From 75f5a4518489fd4d0dd7db90c7f42720a9d2d079 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 15:26:31 +0900 Subject: [PATCH 13/72] ci(reputation): execute semantic RED on alternate hosted pool --- .../reputation-semantic-red-macos.yml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/reputation-semantic-red-macos.yml diff --git a/.github/workflows/reputation-semantic-red-macos.yml b/.github/workflows/reputation-semantic-red-macos.yml new file mode 100644 index 0000000..10277be --- /dev/null +++ b/.github/workflows/reputation-semantic-red-macos.yml @@ -0,0 +1,24 @@ +name: Reputation semantic RED (macOS rescue) + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: wardnet-reputation-red-${{ github.repository }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + semantic-red: + runs-on: macos-15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Verify formatting before semantic RED + run: cargo fmt --all -- --check + - name: Execute hostile reputation contract tests + run: cargo test --locked -p wardnet-reputation-core From 880fc7b90978b8414d09dd3fe65457f92d15344b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 15:31:40 +0900 Subject: [PATCH 14/72] fix(reputation): fail closed on invalid v1 contracts --- crates/wardnet-reputation-core/src/model.rs | 132 +++++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index 2b31229..c043153 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -5,6 +5,50 @@ use serde::{Deserialize, Serialize}; /// Wire schema identifier for the first Wardnet reputation contract family. pub const REPUTATION_SCHEMA_V1: &str = "wardnet.reputation.v1"; +const MAX_TEXT_BYTES_V1: usize = 1_024; +const MAX_LIST_ITEMS_V1: usize = 64; + +fn validate_schema(schema_version: &str) -> Result<(), ContractValidationErrorV1> { + if schema_version == REPUTATION_SCHEMA_V1 { + Ok(()) + } else { + Err(ContractValidationErrorV1::UnsupportedSchema) + } +} + +fn validate_text(value: &str, field: &'static str) -> Result<(), ContractValidationErrorV1> { + if value.trim().is_empty() { + return Err(ContractValidationErrorV1::BlankField(field)); + } + if value.len() > MAX_TEXT_BYTES_V1 { + return Err(ContractValidationErrorV1::BoundExceeded(field)); + } + Ok(()) +} + +fn validate_optional_text( + value: Option<&str>, + field: &'static str, +) -> Result<(), ContractValidationErrorV1> { + if let Some(value) = value { + validate_text(value, field)?; + } + Ok(()) +} + +fn validate_text_list( + values: &[String], + field: &'static str, +) -> Result<(), ContractValidationErrorV1> { + if values.len() > MAX_LIST_ITEMS_V1 { + return Err(ContractValidationErrorV1::BoundExceeded(field)); + } + for value in values { + validate_text(value, field)?; + } + Ok(()) +} + /// Direction of the evaluated destination operation. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -48,6 +92,18 @@ pub struct DestinationSubjectV1 { pub scope: DestinationScopeV1, } +impl DestinationSubjectV1 { + fn validate(&self) -> Result<(), ContractValidationErrorV1> { + validate_text(&self.value, "subject.value")?; + if self.scope == DestinationScopeV1::HostAndSubdomains + && self.kind != DestinationSubjectKindV1::ExactHost + { + return Err(ContractValidationErrorV1::AmbiguousSubjectScope); + } + Ok(()) + } +} + /// Authenticated evaluation context after identity claims have been verified by the service edge. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DestinationContextV1 { @@ -76,6 +132,21 @@ pub struct DestinationContextV1 { impl DestinationContextV1 { /// Validate the bounded contract shape without authenticating caller-controlled identity text. pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + if self.direction != DirectionV1::Outbound { + return Err(ContractValidationErrorV1::WrongDirection); + } + validate_text(&self.tenant_id, "tenant_id")?; + validate_text(&self.workload_id, "workload_id")?; + validate_text(&self.purpose, "purpose")?; + validate_text(&self.operation_id, "operation_id")?; + validate_text(&self.profile_id, "profile_id")?; + self.subject.validate()?; + validate_text(&self.canonicalization_profile, "canonicalization_profile")?; + validate_text( + &self.canonicalization_version, + "canonicalization_version", + )?; Ok(()) } } @@ -135,7 +206,29 @@ pub struct EvidenceRecordV1 { impl EvidenceRecordV1 { /// Validate contract shape and time ordering at an injected evaluation time. - pub fn validate_at(&self, _now_unix: u64) -> Result<(), ContractValidationErrorV1> { + pub fn validate_at(&self, now_unix: u64) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + validate_text(&self.source_id, "source_id")?; + validate_text(&self.producer_record_id, "producer_record_id")?; + validate_text( + &self.producer_record_version, + "producer_record_version", + )?; + self.subject.validate()?; + validate_optional_text(self.producer_severity.as_deref(), "producer_severity")?; + validate_optional_text(self.tenant_id.as_deref(), "tenant_id")?; + validate_optional_text(self.marking.as_deref(), "marking")?; + validate_text(&self.license_ref, "license_ref")?; + validate_text_list(&self.provenance_refs, "provenance_refs")?; + if self.observed_at_unix > self.received_at_unix + || self.received_at_unix > now_unix + || self.valid_from_unix > self.valid_until_unix + { + return Err(ContractValidationErrorV1::InvalidTimeOrder); + } + if self.producer_confidence.is_some_and(|confidence| confidence > 100) { + return Err(ContractValidationErrorV1::InvalidConfidence); + } Ok(()) } } @@ -160,6 +253,17 @@ pub struct SourcePolicyV1 { impl SourcePolicyV1 { /// Validate source-policy shape without fetching or authenticating a source. pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + validate_text(&self.source_id, "source_id")?; + if self.permitted_subject_kinds.is_empty() || self.allowed_purposes.is_empty() { + return Err(ContractValidationErrorV1::EmptySourceEligibility); + } + if self.permitted_subject_kinds.len() > MAX_LIST_ITEMS_V1 { + return Err(ContractValidationErrorV1::BoundExceeded( + "permitted_subject_kinds", + )); + } + validate_text_list(&self.allowed_purposes, "allowed_purposes")?; Ok(()) } } @@ -195,7 +299,24 @@ pub struct PolicySnapshotV1 { impl PolicySnapshotV1 { /// Validate the policy snapshot at an injected evaluation time. - pub fn validate_at(&self, _now_unix: u64) -> Result<(), ContractValidationErrorV1> { + pub fn validate_at(&self, now_unix: u64) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + validate_text(&self.policy_id, "policy_id")?; + if self.mode == EvaluationModeV1::Protect && self.required_sources.is_empty() { + return Err(ContractValidationErrorV1::EmptyRequiredSources); + } + validate_text_list(&self.required_sources, "required_sources")?; + for (index, source) in self.required_sources.iter().enumerate() { + if self.required_sources[..index].contains(source) { + return Err(ContractValidationErrorV1::DuplicateRequiredSource); + } + } + if self.valid_from_unix > self.valid_until_unix + || now_unix < self.valid_from_unix + || now_unix > self.valid_until_unix + { + return Err(ContractValidationErrorV1::InvalidTimeOrder); + } Ok(()) } } @@ -282,6 +403,13 @@ pub struct DecisionEnvelopeV1 { impl DecisionEnvelopeV1 { /// Validate a serialized decision envelope without treating it as an authenticated grant. pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { + validate_schema(&self.schema_version)?; + validate_text(&self.evaluation_id, "evaluation_id")?; + validate_text(&self.policy_id, "policy_id")?; + validate_text_list(&self.evidence_refs, "evidence_refs")?; + if self.evaluated_at_unix > self.expires_at_unix { + return Err(ContractValidationErrorV1::InvalidTimeOrder); + } Ok(()) } } From 3e6bead8e993c391bcd6324cf8fba6bd875ba3d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 15:35:01 +0900 Subject: [PATCH 15/72] style(reputation): apply rustfmt to contract validator --- crates/wardnet-reputation-core/src/model.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index c043153..22f293a 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -143,10 +143,7 @@ impl DestinationContextV1 { validate_text(&self.profile_id, "profile_id")?; self.subject.validate()?; validate_text(&self.canonicalization_profile, "canonicalization_profile")?; - validate_text( - &self.canonicalization_version, - "canonicalization_version", - )?; + validate_text(&self.canonicalization_version, "canonicalization_version")?; Ok(()) } } @@ -210,10 +207,7 @@ impl EvidenceRecordV1 { validate_schema(&self.schema_version)?; validate_text(&self.source_id, "source_id")?; validate_text(&self.producer_record_id, "producer_record_id")?; - validate_text( - &self.producer_record_version, - "producer_record_version", - )?; + validate_text(&self.producer_record_version, "producer_record_version")?; self.subject.validate()?; validate_optional_text(self.producer_severity.as_deref(), "producer_severity")?; validate_optional_text(self.tenant_id.as_deref(), "tenant_id")?; @@ -226,7 +220,10 @@ impl EvidenceRecordV1 { { return Err(ContractValidationErrorV1::InvalidTimeOrder); } - if self.producer_confidence.is_some_and(|confidence| confidence > 100) { + if self + .producer_confidence + .is_some_and(|confidence| confidence > 100) + { return Err(ContractValidationErrorV1::InvalidConfidence); } Ok(()) From 025453374c9259edeb8fbda5fa9181199f422bbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 15:36:02 +0900 Subject: [PATCH 16/72] ci(reputation): remove completed semantic rescue lane --- .../reputation-semantic-red-macos.yml | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 .github/workflows/reputation-semantic-red-macos.yml diff --git a/.github/workflows/reputation-semantic-red-macos.yml b/.github/workflows/reputation-semantic-red-macos.yml deleted file mode 100644 index 10277be..0000000 --- a/.github/workflows/reputation-semantic-red-macos.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Reputation semantic RED (macOS rescue) - -on: - pull_request: - -permissions: - contents: read - -concurrency: - group: wardnet-reputation-red-${{ github.repository }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - semantic-red: - runs-on: macos-15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Verify formatting before semantic RED - run: cargo fmt --all -- --check - - name: Execute hostile reputation contract tests - run: cargo test --locked -p wardnet-reputation-core From 87d390cb153a7e02510330b59dc37cd2c40feea4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:01:01 +0900 Subject: [PATCH 17/72] test(reputation): bind decisions to request and evidence identity --- .../tests/decision_binding.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 crates/wardnet-reputation-core/tests/decision_binding.rs diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs new file mode 100644 index 0000000..94f0260 --- /dev/null +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -0,0 +1,60 @@ +use serde_json::{json, Value}; +use wardnet_reputation_core::{ContractValidationErrorV1, DecisionEnvelopeV1, REPUTATION_SCHEMA_V1}; + +const NOW: u64 = 1_788_652_800; + +fn decision_json(workload_id: &str, evidence_generation: &str) -> Value { + json!({ + "schema_version": REPUTATION_SCHEMA_V1, + "evaluation_id": "eval-0001", + "policy_id": "protect-default", + "policy_revision": 1, + "assessment": "unknown", + "evidence_health": "fresh", + "action": "deny", + "reason": "unknown_destination", + "evaluated_at_unix": NOW, + "expires_at_unix": NOW + 60, + "evidence_refs": [], + "context": { + "schema_version": REPUTATION_SCHEMA_V1, + "direction": "outbound", + "tenant_id": "tenant-example", + "workload_id": workload_id, + "purpose": "package_metadata", + "operation_id": "op-0001", + "profile_id": "protect-default", + "subject": { + "kind": "exact_host", + "value": "updates.example.invalid", + "scope": "exact" + }, + "canonicalization_profile": "egressweave-offline-fixture", + "canonicalization_version": "1" + }, + "evidence_generation": evidence_generation + }) +} + +#[test] +fn decision_envelope_rejects_invalid_authenticated_context_binding() { + let decision: DecisionEnvelopeV1 = serde_json::from_value(decision_json("", "snapshot-42")) + .expect("v1 decision envelope should deserialize"); + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::BlankField("workload_id")) + ); +} + +#[test] +fn decision_envelope_rejects_missing_evidence_generation_binding() { + let decision: DecisionEnvelopeV1 = + serde_json::from_value(decision_json("workload-example", "")) + .expect("v1 decision envelope should deserialize"); + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::BlankField("evidence_generation")) + ); +} From f7f6a9d8804b6b9b0c46209060b59df2a2fa5110 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:01:11 +0900 Subject: [PATCH 18/72] ci(reputation): execute decision-binding RED on hosted macOS --- .../reputation-decision-binding-macos.yml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/reputation-decision-binding-macos.yml diff --git a/.github/workflows/reputation-decision-binding-macos.yml b/.github/workflows/reputation-decision-binding-macos.yml new file mode 100644 index 0000000..588ceb6 --- /dev/null +++ b/.github/workflows/reputation-decision-binding-macos.yml @@ -0,0 +1,24 @@ +name: Reputation decision binding (macOS rescue) + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: wardnet-reputation-decision-binding-${{ github.repository }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + reputation-contract: + runs-on: macos-15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Verify formatting + run: cargo fmt --all -- --check + - name: Execute reputation contract tests + run: cargo test --locked -p wardnet-reputation-core From 9a07c7a1b7b1fe37117ec366e42a9ce8bfdbcc34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:02:39 +0900 Subject: [PATCH 19/72] style(reputation): format decision-binding RED --- crates/wardnet-reputation-core/tests/decision_binding.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index 94f0260..3280dd3 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -1,5 +1,7 @@ use serde_json::{json, Value}; -use wardnet_reputation_core::{ContractValidationErrorV1, DecisionEnvelopeV1, REPUTATION_SCHEMA_V1}; +use wardnet_reputation_core::{ + ContractValidationErrorV1, DecisionEnvelopeV1, REPUTATION_SCHEMA_V1, +}; const NOW: u64 = 1_788_652_800; @@ -38,8 +40,9 @@ fn decision_json(workload_id: &str, evidence_generation: &str) -> Value { #[test] fn decision_envelope_rejects_invalid_authenticated_context_binding() { - let decision: DecisionEnvelopeV1 = serde_json::from_value(decision_json("", "snapshot-42")) - .expect("v1 decision envelope should deserialize"); + let decision: DecisionEnvelopeV1 = + serde_json::from_value(decision_json("", "snapshot-42")) + .expect("v1 decision envelope should deserialize"); assert_eq!( decision.validate(), From 64ea63b6efa5603097d0e15c167da6e317307166 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:04:40 +0900 Subject: [PATCH 20/72] style(reputation): apply exact rustfmt to binding RED --- crates/wardnet-reputation-core/tests/decision_binding.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index 3280dd3..d8e97a5 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -1,4 +1,4 @@ -use serde_json::{json, Value}; +use serde_json::{Value, json}; use wardnet_reputation_core::{ ContractValidationErrorV1, DecisionEnvelopeV1, REPUTATION_SCHEMA_V1, }; @@ -40,9 +40,8 @@ fn decision_json(workload_id: &str, evidence_generation: &str) -> Value { #[test] fn decision_envelope_rejects_invalid_authenticated_context_binding() { - let decision: DecisionEnvelopeV1 = - serde_json::from_value(decision_json("", "snapshot-42")) - .expect("v1 decision envelope should deserialize"); + let decision: DecisionEnvelopeV1 = serde_json::from_value(decision_json("", "snapshot-42")) + .expect("v1 decision envelope should deserialize"); assert_eq!( decision.validate(), From 0cea14e76f9b7ad758fbc60235310f7af26b27e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:08:06 +0900 Subject: [PATCH 21/72] fix(reputation): bind decisions to context and evidence generation --- crates/wardnet-reputation-core/src/model.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index 22f293a..5debf15 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -377,10 +377,14 @@ pub struct DecisionEnvelopeV1 { pub schema_version: String, /// Unique evaluation identifier. pub evaluation_id: String, + /// Authenticated request and canonical destination identity evaluated by this decision. + pub context: DestinationContextV1, /// Policy identifier used for the decision. pub policy_id: String, /// Exact policy revision used for the decision. pub policy_revision: u64, + /// Immutable evidence snapshot generation evaluated by this decision. + pub evidence_generation: String, /// Deterministic security assessment. pub assessment: ReputationAssessmentV1, /// Required evidence health. @@ -402,7 +406,9 @@ impl DecisionEnvelopeV1 { pub fn validate(&self) -> Result<(), ContractValidationErrorV1> { validate_schema(&self.schema_version)?; validate_text(&self.evaluation_id, "evaluation_id")?; + self.context.validate()?; validate_text(&self.policy_id, "policy_id")?; + validate_text(&self.evidence_generation, "evidence_generation")?; validate_text_list(&self.evidence_refs, "evidence_refs")?; if self.evaluated_at_unix > self.expires_at_unix { return Err(ContractValidationErrorV1::InvalidTimeOrder); From 42b26eaa20b1d806aa3b7fd1f337c295e7b549dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:09:14 +0900 Subject: [PATCH 22/72] ci(reputation): retire completed decision-binding rescue --- .../reputation-decision-binding-macos.yml | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 .github/workflows/reputation-decision-binding-macos.yml diff --git a/.github/workflows/reputation-decision-binding-macos.yml b/.github/workflows/reputation-decision-binding-macos.yml deleted file mode 100644 index 588ceb6..0000000 --- a/.github/workflows/reputation-decision-binding-macos.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Reputation decision binding (macOS rescue) - -on: - pull_request: - -permissions: - contents: read - -concurrency: - group: wardnet-reputation-decision-binding-${{ github.repository }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - reputation-contract: - runs-on: macos-15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Verify formatting - run: cargo fmt --all -- --check - - name: Execute reputation contract tests - run: cargo test --locked -p wardnet-reputation-core From 23d2b3fba93e154c849dd7897b4a48342c48474e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:19:42 +0900 Subject: [PATCH 23/72] test(reputation): require provenance for enforcement evidence --- crates/wardnet-reputation-core/tests/contract.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs index 2e241f9..54e571e 100644 --- a/crates/wardnet-reputation-core/tests/contract.rs +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -144,6 +144,17 @@ fn rejects_invalid_evidence_time_order_and_confidence() { ); } +#[test] +fn rejects_enforcement_evidence_without_provenance() { + let mut candidate = evidence(); + candidate.provenance_refs.clear(); + + assert!( + candidate.validate_at(NOW).is_err(), + "enforcement-eligible evidence without provenance must fail closed" + ); +} + #[test] fn rejects_empty_source_eligibility() { let mut candidate = source_policy(); From 7e058de59995bd52eead0f264f337908f805c0d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:19:58 +0900 Subject: [PATCH 24/72] ci(reputation): execute provenance RED on hosted macOS --- .../workflows/reputation-provenance-macos.yml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/reputation-provenance-macos.yml diff --git a/.github/workflows/reputation-provenance-macos.yml b/.github/workflows/reputation-provenance-macos.yml new file mode 100644 index 0000000..0938458 --- /dev/null +++ b/.github/workflows/reputation-provenance-macos.yml @@ -0,0 +1,24 @@ +name: Reputation provenance (macOS rescue) + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: wardnet-reputation-provenance-${{ github.repository }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + reputation-contract: + runs-on: macos-15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Verify formatting + run: cargo fmt --all -- --check + - name: Execute reputation contract tests + run: cargo test --locked -p wardnet-reputation-core From 85770857c0f8a5bff21dd7722960209e45b19d5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:21:22 +0900 Subject: [PATCH 25/72] ci(reputation): bind provenance RED to exact source head --- .github/workflows/reputation-provenance-macos.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/reputation-provenance-macos.yml b/.github/workflows/reputation-provenance-macos.yml index 0938458..b75559a 100644 --- a/.github/workflows/reputation-provenance-macos.yml +++ b/.github/workflows/reputation-provenance-macos.yml @@ -13,8 +13,14 @@ concurrency: jobs: reputation-contract: runs-on: macos-15 + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source head + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable From 71086b18d7f3cf91f1e9952a9183af6f43874811 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:24:38 +0900 Subject: [PATCH 26/72] fix(reputation): reject untraceable enforcement evidence --- crates/wardnet-reputation-core/src/model.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index 5debf15..a1c4b83 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -214,6 +214,9 @@ impl EvidenceRecordV1 { validate_optional_text(self.marking.as_deref(), "marking")?; validate_text(&self.license_ref, "license_ref")?; validate_text_list(&self.provenance_refs, "provenance_refs")?; + if self.enforcement_eligible && self.provenance_refs.is_empty() { + return Err(ContractValidationErrorV1::MissingEnforcementProvenance); + } if self.observed_at_unix > self.received_at_unix || self.received_at_unix > now_unix || self.valid_from_unix > self.valid_until_unix @@ -440,4 +443,6 @@ pub enum ContractValidationErrorV1 { InvalidConfidence, /// A source policy does not permit any subject kind or purpose. EmptySourceEligibility, + /// Evidence eligible for enforcement has no provenance reference. + MissingEnforcementProvenance, } From f549c3dce95b8a96fd78af9a15047801b5113269 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:26:40 +0900 Subject: [PATCH 27/72] ci(reputation): retire completed provenance rescue --- .../workflows/reputation-provenance-macos.yml | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 .github/workflows/reputation-provenance-macos.yml diff --git a/.github/workflows/reputation-provenance-macos.yml b/.github/workflows/reputation-provenance-macos.yml deleted file mode 100644 index b75559a..0000000 --- a/.github/workflows/reputation-provenance-macos.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Reputation provenance (macOS rescue) - -on: - pull_request: - -permissions: - contents: read - -concurrency: - group: wardnet-reputation-provenance-${{ github.repository }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - reputation-contract: - runs-on: macos-15 - env: - EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Verify exact source head - run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Verify formatting - run: cargo fmt --all -- --check - - name: Execute reputation contract tests - run: cargo test --locked -p wardnet-reputation-core From c06999993b6a8fe70ae5f881b6d916ab49911854 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:28:04 +0900 Subject: [PATCH 28/72] test(reputation): reject untraceable adverse decisions --- .../tests/decision_binding.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index d8e97a5..42d2762 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -60,3 +60,18 @@ fn decision_envelope_rejects_missing_evidence_generation_binding() { Err(ContractValidationErrorV1::BlankField("evidence_generation")) ); } + +#[test] +fn decision_envelope_rejects_untraceable_adverse_assessment() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("known_malicious"); + value["reason"] = json!("known_malicious"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::MissingDecisionEvidence) + ); +} From 3d83ec71cba2e9e10f1b6078729cc34acf6fd070 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:28:11 +0900 Subject: [PATCH 29/72] ci(reputation): execute adverse-evidence RED on hosted macOS --- .../reputation-decision-evidence-macos.yml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/reputation-decision-evidence-macos.yml diff --git a/.github/workflows/reputation-decision-evidence-macos.yml b/.github/workflows/reputation-decision-evidence-macos.yml new file mode 100644 index 0000000..bd05bcc --- /dev/null +++ b/.github/workflows/reputation-decision-evidence-macos.yml @@ -0,0 +1,30 @@ +name: Reputation decision evidence (macOS rescue) + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: wardnet-reputation-decision-evidence-${{ github.repository }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + reputation-contract: + runs-on: macos-15 + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source head + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Verify formatting + run: cargo fmt --all -- --check + - name: Execute reputation contract tests + run: cargo test --locked -p wardnet-reputation-core From ba962a105d9e38f48cd9df206733314fd2a4b56c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:30:07 +0900 Subject: [PATCH 30/72] test(reputation): make adverse-evidence RED executable --- crates/wardnet-reputation-core/tests/decision_binding.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index 42d2762..3d74b4f 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -70,8 +70,8 @@ fn decision_envelope_rejects_untraceable_adverse_assessment() { let decision: DecisionEnvelopeV1 = serde_json::from_value(value).expect("v1 decision envelope should deserialize"); - assert_eq!( - decision.validate(), - Err(ContractValidationErrorV1::MissingDecisionEvidence) + assert!( + decision.validate().is_err(), + "known-malicious decisions without evidence references must fail closed" ); } From ef8fed6edf65b14389703130000bad4769eafe4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:31:48 +0900 Subject: [PATCH 31/72] fix(reputation): reject untraceable adverse decisions --- crates/wardnet-reputation-core/src/model.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index a1c4b83..af73ce5 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -413,6 +413,13 @@ impl DecisionEnvelopeV1 { validate_text(&self.policy_id, "policy_id")?; validate_text(&self.evidence_generation, "evidence_generation")?; validate_text_list(&self.evidence_refs, "evidence_refs")?; + if matches!( + self.assessment, + ReputationAssessmentV1::KnownMalicious | ReputationAssessmentV1::Suspicious + ) && self.evidence_refs.is_empty() + { + return Err(ContractValidationErrorV1::MissingDecisionEvidence); + } if self.evaluated_at_unix > self.expires_at_unix { return Err(ContractValidationErrorV1::InvalidTimeOrder); } @@ -445,4 +452,6 @@ pub enum ContractValidationErrorV1 { EmptySourceEligibility, /// Evidence eligible for enforcement has no provenance reference. MissingEnforcementProvenance, + /// An adverse decision assessment has no evidence reference for SOC traceability. + MissingDecisionEvidence, } From 56292904fddbb249212c54758def26ff72b801c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:33:35 +0900 Subject: [PATCH 32/72] ci(reputation): retire adverse-evidence rescue --- .../reputation-decision-evidence-macos.yml | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 .github/workflows/reputation-decision-evidence-macos.yml diff --git a/.github/workflows/reputation-decision-evidence-macos.yml b/.github/workflows/reputation-decision-evidence-macos.yml deleted file mode 100644 index bd05bcc..0000000 --- a/.github/workflows/reputation-decision-evidence-macos.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Reputation decision evidence (macOS rescue) - -on: - pull_request: - -permissions: - contents: read - -concurrency: - group: wardnet-reputation-decision-evidence-${{ github.repository }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - reputation-contract: - runs-on: macos-15 - env: - EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Verify exact source head - run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Verify formatting - run: cargo fmt --all -- --check - - name: Execute reputation contract tests - run: cargo test --locked -p wardnet-reputation-core From f6868216e7163e0b7c36287d4e2ec7382bb5a99a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:37:35 +0900 Subject: [PATCH 33/72] test(reputation): forbid known-malicious allow decisions --- .../tests/decision_binding.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index 3d74b4f..5899677 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -75,3 +75,20 @@ fn decision_envelope_rejects_untraceable_adverse_assessment() { "known-malicious decisions without evidence references must fail closed" ); } + +#[test] +fn decision_envelope_rejects_known_malicious_allow() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("known_malicious"); + value["action"] = json!("allow"); + value["reason"] = json!("known_malicious"); + value["evidence_refs"] = json!(["urn:wardnet:evidence:record-1"]); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "known-malicious assessments must never serialize as an allow action" + ); +} From 7e33ee8c0086073faf1f8c4bb8cd9df62ceec5da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:37:51 +0900 Subject: [PATCH 34/72] ci(reputation): execute action-consistency RED on hosted macOS --- .../reputation-action-consistency-macos.yml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/reputation-action-consistency-macos.yml diff --git a/.github/workflows/reputation-action-consistency-macos.yml b/.github/workflows/reputation-action-consistency-macos.yml new file mode 100644 index 0000000..5dc90ca --- /dev/null +++ b/.github/workflows/reputation-action-consistency-macos.yml @@ -0,0 +1,30 @@ +name: Reputation action consistency (macOS rescue) + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: wardnet-reputation-action-consistency-${{ github.repository }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + reputation-contract: + runs-on: macos-15 + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source head + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Verify formatting + run: cargo fmt --all -- --check + - name: Execute reputation contract tests + run: cargo test --locked -p wardnet-reputation-core From 94c0ddeae6750ed4d2033b859be3472a746d03ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:39:45 +0900 Subject: [PATCH 35/72] fix(reputation): fail closed on adverse allow actions --- crates/wardnet-reputation-core/src/model.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index af73ce5..01cf098 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -413,13 +413,16 @@ impl DecisionEnvelopeV1 { validate_text(&self.policy_id, "policy_id")?; validate_text(&self.evidence_generation, "evidence_generation")?; validate_text_list(&self.evidence_refs, "evidence_refs")?; - if matches!( + let adverse_assessment = matches!( self.assessment, ReputationAssessmentV1::KnownMalicious | ReputationAssessmentV1::Suspicious - ) && self.evidence_refs.is_empty() - { + ); + if adverse_assessment && self.evidence_refs.is_empty() { return Err(ContractValidationErrorV1::MissingDecisionEvidence); } + if adverse_assessment && self.action == PolicyActionV1::Allow { + return Err(ContractValidationErrorV1::UnsafeAdverseAllow); + } if self.evaluated_at_unix > self.expires_at_unix { return Err(ContractValidationErrorV1::InvalidTimeOrder); } @@ -454,4 +457,6 @@ pub enum ContractValidationErrorV1 { MissingEnforcementProvenance, /// An adverse decision assessment has no evidence reference for SOC traceability. MissingDecisionEvidence, + /// An adverse assessment attempts to serialize as an allow action. + UnsafeAdverseAllow, } From d34cc92be22366b8e18e6a203bc6455eef52c1b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:40:39 +0900 Subject: [PATCH 36/72] ci(reputation): retire completed action-consistency rescue --- .../reputation-action-consistency-macos.yml | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 .github/workflows/reputation-action-consistency-macos.yml diff --git a/.github/workflows/reputation-action-consistency-macos.yml b/.github/workflows/reputation-action-consistency-macos.yml deleted file mode 100644 index 5dc90ca..0000000 --- a/.github/workflows/reputation-action-consistency-macos.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Reputation action consistency (macOS rescue) - -on: - pull_request: - -permissions: - contents: read - -concurrency: - group: wardnet-reputation-action-consistency-${{ github.repository }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - reputation-contract: - runs-on: macos-15 - env: - EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Verify exact source head - run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Verify formatting - run: cargo fmt --all -- --check - - name: Execute reputation contract tests - run: cargo test --locked -p wardnet-reputation-core From 913cf4e9c1589dff96bb68a00463ac8e8f61fa59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:58:27 +0900 Subject: [PATCH 37/72] test(reputation): reject unavailable-authority allow --- .../tests/decision_binding.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index 5899677..7ffa1e5 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -92,3 +92,19 @@ fn decision_envelope_rejects_known_malicious_allow() { "known-malicious assessments must never serialize as an allow action" ); } + +#[test] +fn decision_envelope_rejects_unavailable_required_authority_allow() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["evidence_health"] = json!("unavailable"); + value["action"] = json!("allow"); + value["reason"] = json!("required_authority_unavailable"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "required-authority outage must never serialize as a reputation allow" + ); +} From 414fb98743c729f4a4897f343a7f158882a9a598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:59:22 +0900 Subject: [PATCH 38/72] fix(reputation): fail closed on unhealthy authority allow --- crates/wardnet-reputation-core/src/model.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index 01cf098..adf2d03 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -423,6 +423,13 @@ impl DecisionEnvelopeV1 { if adverse_assessment && self.action == PolicyActionV1::Allow { return Err(ContractValidationErrorV1::UnsafeAdverseAllow); } + let unhealthy_required_authority = matches!( + self.evidence_health, + EvidenceHealthV1::Expired | EvidenceHealthV1::Unavailable + ); + if unhealthy_required_authority && self.action == PolicyActionV1::Allow { + return Err(ContractValidationErrorV1::UnsafeUnhealthyEvidenceAllow); + } if self.evaluated_at_unix > self.expires_at_unix { return Err(ContractValidationErrorV1::InvalidTimeOrder); } @@ -459,4 +466,6 @@ pub enum ContractValidationErrorV1 { MissingDecisionEvidence, /// An adverse assessment attempts to serialize as an allow action. UnsafeAdverseAllow, + /// Expired or unavailable required evidence attempts to serialize as an allow action. + UnsafeUnhealthyEvidenceAllow, } From c83149cf9f0884d9bdf681eacaaeb5b70eb03af2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:16:58 +0900 Subject: [PATCH 39/72] test(reputation): reject contradictory decision reasons --- .../tests/decision_binding.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index 7ffa1e5..adbdb44 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -108,3 +108,48 @@ fn decision_envelope_rejects_unavailable_required_authority_allow() { "required-authority outage must never serialize as a reputation allow" ); } + +#[test] +fn decision_envelope_rejects_known_malicious_with_non_adverse_reason() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("known_malicious"); + value["reason"] = json!("unknown_destination"); + value["evidence_refs"] = json!(["urn:wardnet:evidence:record-1"]); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "known-malicious assessments must not serialize with a contradictory SOC reason" + ); +} + +#[test] +fn decision_envelope_rejects_adverse_reason_for_unknown_assessment() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["reason"] = json!("suspicious"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "adverse SOC reasons must not be detached from the matching adverse assessment" + ); +} + +#[test] +fn decision_envelope_accepts_consistent_suspicious_denial() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("suspicious"); + value["reason"] = json!("suspicious"); + value["evidence_refs"] = json!(["urn:wardnet:evidence:record-2"]); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + decision + .validate() + .expect("consistent suspicious deny must remain a valid reputation decision"); +} From ebf1dbb31a3455ff4c0bee44d0ba5aa6f1bde6fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:17:35 +0900 Subject: [PATCH 40/72] ci(reputation): verify contradictory reason RED and causal fix --- .../workflows/reputation-reason-repair.yml | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/reputation-reason-repair.yml diff --git a/.github/workflows/reputation-reason-repair.yml b/.github/workflows/reputation-reason-repair.yml new file mode 100644 index 0000000..5f37054 --- /dev/null +++ b/.github/workflows/reputation-reason-repair.yml @@ -0,0 +1,87 @@ +name: Reputation reason repair + +on: + push: + branches: + - feat/site-reputation-contract-v1 + +permissions: + contents: write + +concurrency: + group: wardnet-reputation-reason-repair-${{ github.ref }} + cancel-in-progress: false + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: macos-15-arm64 + timeout-minutes: 45 + steps: + - name: Checkout exact source head + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Assert exact source identity + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + remote_head="$(git ls-remote origin refs/heads/feat/site-reputation-contract-v1 | awk '{print $1}')" + test "${remote_head}" = "${GITHUB_SHA}" + + - name: Prove hostile decision-reason regression is RED + shell: bash + run: | + set -euo pipefail + set +e + cargo test --locked -p wardnet-reputation-core --test decision_binding + status=$? + set -e + test "${status}" -ne 0 + + - name: Apply minimum causal reason-binding repair + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('crates/wardnet-reputation-core/src/model.rs') + text = path.read_text() + old = ''' validate_text(&self.evidence_generation, "evidence_generation")?;\n validate_text_list(&self.evidence_refs, "evidence_refs")?;\n let adverse_assessment = matches!(\n''' + new = ''' validate_text(&self.evidence_generation, "evidence_generation")?;\n validate_text_list(&self.evidence_refs, "evidence_refs")?;\n let reason_matches_assessment = match self.assessment {\n ReputationAssessmentV1::KnownMalicious => {\n self.reason == DecisionReasonV1::KnownMalicious\n }\n ReputationAssessmentV1::Suspicious => self.reason == DecisionReasonV1::Suspicious,\n ReputationAssessmentV1::Unknown => !matches!(\n self.reason,\n DecisionReasonV1::KnownMalicious | DecisionReasonV1::Suspicious\n ),\n };\n if !reason_matches_assessment {\n return Err(ContractValidationErrorV1::InconsistentAssessmentReason);\n }\n let adverse_assessment = matches!(\n''' + if old not in text: + raise SystemExit('decision validation insertion point moved') + text = text.replace(old, new, 1) + old_error = ''' /// An adverse assessment attempts to serialize as an allow action.\n UnsafeAdverseAllow,\n''' + new_error = ''' /// The machine-readable reason contradicts the serialized security assessment.\n InconsistentAssessmentReason,\n /// An adverse assessment attempts to serialize as an allow action.\n UnsafeAdverseAllow,\n''' + if old_error not in text: + raise SystemExit('validation error insertion point moved') + text = text.replace(old_error, new_error, 1) + path.write_text(text) + PY + + - name: Verify causal GREEN + shell: bash + run: | + set -euo pipefail + cargo fmt --check + cargo test --locked -p wardnet-reputation-core --test decision_binding + cargo test --locked -p wardnet-reputation-core + git diff --check + + - name: Commit only the causal source repair + shell: bash + run: | + set -euo pipefail + test "$(git diff --name-only)" = "crates/wardnet-reputation-core/src/model.rs" + remote_head="$(git ls-remote origin refs/heads/feat/site-reputation-contract-v1 | awk '{print $1}')" + test "${remote_head}" = "${GITHUB_SHA}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add crates/wardnet-reputation-core/src/model.rs + git commit -m "fix(reputation): bind SOC reasons to assessments" + git push origin HEAD:feat/site-reputation-contract-v1 From 92b7c04b9d3fb8354e15d98eaf212a0849f94cfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:11:36 +0900 Subject: [PATCH 41/72] fix(reputation): bind decision reason to assessment --- crates/wardnet-reputation-core/src/model.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index adf2d03..a6fffae 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -373,6 +373,20 @@ pub enum DecisionReasonV1 { InvalidContract, } +fn reason_matches_assessment( + assessment: ReputationAssessmentV1, + reason: DecisionReasonV1, +) -> bool { + match assessment { + ReputationAssessmentV1::KnownMalicious => reason == DecisionReasonV1::KnownMalicious, + ReputationAssessmentV1::Suspicious => reason == DecisionReasonV1::Suspicious, + ReputationAssessmentV1::Unknown => !matches!( + reason, + DecisionReasonV1::KnownMalicious | DecisionReasonV1::Suspicious + ), + } +} + /// Explainable pure-core decision envelope; it is not proof that traffic was actually blocked. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DecisionEnvelopeV1 { @@ -413,6 +427,9 @@ impl DecisionEnvelopeV1 { validate_text(&self.policy_id, "policy_id")?; validate_text(&self.evidence_generation, "evidence_generation")?; validate_text_list(&self.evidence_refs, "evidence_refs")?; + if !reason_matches_assessment(self.assessment, self.reason) { + return Err(ContractValidationErrorV1::InconsistentAssessmentReason); + } let adverse_assessment = matches!( self.assessment, ReputationAssessmentV1::KnownMalicious | ReputationAssessmentV1::Suspicious @@ -464,6 +481,8 @@ pub enum ContractValidationErrorV1 { MissingEnforcementProvenance, /// An adverse decision assessment has no evidence reference for SOC traceability. MissingDecisionEvidence, + /// Decision assessment and machine-readable reason contradict each other. + InconsistentAssessmentReason, /// An adverse assessment attempts to serialize as an allow action. UnsafeAdverseAllow, /// Expired or unavailable required evidence attempts to serialize as an allow action. From 28e4bf35e21db7bd3312591ef15c51ce05c3f241 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:12:09 +0900 Subject: [PATCH 42/72] ci(reputation): remove completed reason repair harness --- .../workflows/reputation-reason-repair.yml | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 .github/workflows/reputation-reason-repair.yml diff --git a/.github/workflows/reputation-reason-repair.yml b/.github/workflows/reputation-reason-repair.yml deleted file mode 100644 index 5f37054..0000000 --- a/.github/workflows/reputation-reason-repair.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Reputation reason repair - -on: - push: - branches: - - feat/site-reputation-contract-v1 - -permissions: - contents: write - -concurrency: - group: wardnet-reputation-reason-repair-${{ github.ref }} - cancel-in-progress: false - -jobs: - repair: - if: github.actor != 'github-actions[bot]' - runs-on: macos-15-arm64 - timeout-minutes: 45 - steps: - - name: Checkout exact source head - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - - name: Assert exact source identity - shell: bash - run: | - set -euo pipefail - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - remote_head="$(git ls-remote origin refs/heads/feat/site-reputation-contract-v1 | awk '{print $1}')" - test "${remote_head}" = "${GITHUB_SHA}" - - - name: Prove hostile decision-reason regression is RED - shell: bash - run: | - set -euo pipefail - set +e - cargo test --locked -p wardnet-reputation-core --test decision_binding - status=$? - set -e - test "${status}" -ne 0 - - - name: Apply minimum causal reason-binding repair - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - path = Path('crates/wardnet-reputation-core/src/model.rs') - text = path.read_text() - old = ''' validate_text(&self.evidence_generation, "evidence_generation")?;\n validate_text_list(&self.evidence_refs, "evidence_refs")?;\n let adverse_assessment = matches!(\n''' - new = ''' validate_text(&self.evidence_generation, "evidence_generation")?;\n validate_text_list(&self.evidence_refs, "evidence_refs")?;\n let reason_matches_assessment = match self.assessment {\n ReputationAssessmentV1::KnownMalicious => {\n self.reason == DecisionReasonV1::KnownMalicious\n }\n ReputationAssessmentV1::Suspicious => self.reason == DecisionReasonV1::Suspicious,\n ReputationAssessmentV1::Unknown => !matches!(\n self.reason,\n DecisionReasonV1::KnownMalicious | DecisionReasonV1::Suspicious\n ),\n };\n if !reason_matches_assessment {\n return Err(ContractValidationErrorV1::InconsistentAssessmentReason);\n }\n let adverse_assessment = matches!(\n''' - if old not in text: - raise SystemExit('decision validation insertion point moved') - text = text.replace(old, new, 1) - old_error = ''' /// An adverse assessment attempts to serialize as an allow action.\n UnsafeAdverseAllow,\n''' - new_error = ''' /// The machine-readable reason contradicts the serialized security assessment.\n InconsistentAssessmentReason,\n /// An adverse assessment attempts to serialize as an allow action.\n UnsafeAdverseAllow,\n''' - if old_error not in text: - raise SystemExit('validation error insertion point moved') - text = text.replace(old_error, new_error, 1) - path.write_text(text) - PY - - - name: Verify causal GREEN - shell: bash - run: | - set -euo pipefail - cargo fmt --check - cargo test --locked -p wardnet-reputation-core --test decision_binding - cargo test --locked -p wardnet-reputation-core - git diff --check - - - name: Commit only the causal source repair - shell: bash - run: | - set -euo pipefail - test "$(git diff --name-only)" = "crates/wardnet-reputation-core/src/model.rs" - remote_head="$(git ls-remote origin refs/heads/feat/site-reputation-contract-v1 | awk '{print $1}')" - test "${remote_head}" = "${GITHUB_SHA}" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add crates/wardnet-reputation-core/src/model.rs - git commit -m "fix(reputation): bind SOC reasons to assessments" - git push origin HEAD:feat/site-reputation-contract-v1 From f1ebc06a2c384e4c849dcaa7045e6cc9db14387e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:16:19 +0900 Subject: [PATCH 43/72] test(reputation): preserve authority-failure precedence --- .../tests/decision_binding.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index adbdb44..495bbb8 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -153,3 +153,19 @@ fn decision_envelope_accepts_consistent_suspicious_denial() { .validate() .expect("consistent suspicious deny must remain a valid reputation decision"); } + +#[test] +fn decision_envelope_accepts_authority_failure_reason_over_adverse_assessment() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["assessment"] = json!("suspicious"); + value["evidence_health"] = json!("unavailable"); + value["reason"] = json!("required_authority_unavailable"); + value["evidence_refs"] = json!(["urn:wardnet:evidence:record-2"]); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + decision.validate().expect( + "higher-precedence required-authority failure must remain explainable without erasing the adverse assessment", + ); +} From f1e09507aa743a6398876152be6ce672f7358681 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:31:36 +0900 Subject: [PATCH 44/72] test(reputation): execute authority-precedence RED --- .../reputation-authority-precedence-red.yml | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/reputation-authority-precedence-red.yml diff --git a/.github/workflows/reputation-authority-precedence-red.yml b/.github/workflows/reputation-authority-precedence-red.yml new file mode 100644 index 0000000..5a56c39 --- /dev/null +++ b/.github/workflows/reputation-authority-precedence-red.yml @@ -0,0 +1,27 @@ +name: Reputation authority precedence RED + +on: + pull_request: + paths: + - '.github/workflows/reputation-authority-precedence-red.yml' + +permissions: + contents: read + +jobs: + red: + runs-on: macos-15-arm64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: f1ebc06a2c384e4c849dcaa7045e6cc9db14387e + fetch-depth: 1 + - name: Assert exact RED source + run: test "$(git rev-parse HEAD)" = "f1ebc06a2c384e4c849dcaa7045e6cc9db14387e" + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Check formatting + run: cargo fmt --check + - name: Run authority-precedence regression + run: cargo test --locked -p wardnet-reputation-core decision_envelope_accepts_authority_failure_reason_over_adverse_assessment -- --exact From e040201660c3a9d76deb91a81dd4c53b3ca9e5e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:34:04 +0900 Subject: [PATCH 45/72] fix(reputation): preserve authority-failure reason precedence --- crates/wardnet-reputation-core/src/model.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index a6fffae..e2a2188 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -375,14 +375,22 @@ pub enum DecisionReasonV1 { fn reason_matches_assessment( assessment: ReputationAssessmentV1, + evidence_health: EvidenceHealthV1, reason: DecisionReasonV1, ) -> bool { + if matches!( + evidence_health, + EvidenceHealthV1::Expired | EvidenceHealthV1::Unavailable + ) { + return reason == DecisionReasonV1::RequiredAuthorityUnavailable; + } + match assessment { ReputationAssessmentV1::KnownMalicious => reason == DecisionReasonV1::KnownMalicious, ReputationAssessmentV1::Suspicious => reason == DecisionReasonV1::Suspicious, - ReputationAssessmentV1::Unknown => !matches!( + ReputationAssessmentV1::Unknown => matches!( reason, - DecisionReasonV1::KnownMalicious | DecisionReasonV1::Suspicious + DecisionReasonV1::UnknownDestination | DecisionReasonV1::InvalidContract ), } } @@ -427,7 +435,7 @@ impl DecisionEnvelopeV1 { validate_text(&self.policy_id, "policy_id")?; validate_text(&self.evidence_generation, "evidence_generation")?; validate_text_list(&self.evidence_refs, "evidence_refs")?; - if !reason_matches_assessment(self.assessment, self.reason) { + if !reason_matches_assessment(self.assessment, self.evidence_health, self.reason) { return Err(ContractValidationErrorV1::InconsistentAssessmentReason); } let adverse_assessment = matches!( From 2e8f90be62ebc8a6d3304b15f1958193901d7306 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:35:26 +0900 Subject: [PATCH 46/72] ci(reputation): verify authority-precedence causal fix --- .../reputation-authority-precedence-red.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/reputation-authority-precedence-red.yml b/.github/workflows/reputation-authority-precedence-red.yml index 5a56c39..7d432e2 100644 --- a/.github/workflows/reputation-authority-precedence-red.yml +++ b/.github/workflows/reputation-authority-precedence-red.yml @@ -1,4 +1,4 @@ -name: Reputation authority precedence RED +name: Reputation authority precedence verification on: pull_request: @@ -9,19 +9,21 @@ permissions: contents: read jobs: - red: + verify: runs-on: macos-15-arm64 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: f1ebc06a2c384e4c849dcaa7045e6cc9db14387e + ref: e040201660c3a9d76deb91a81dd4c53b3ca9e5e2 fetch-depth: 1 - - name: Assert exact RED source - run: test "$(git rev-parse HEAD)" = "f1ebc06a2c384e4c849dcaa7045e6cc9db14387e" + - name: Assert exact causal GREEN source + run: test "$(git rev-parse HEAD)" = "e040201660c3a9d76deb91a81dd4c53b3ca9e5e2" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable - name: Check formatting run: cargo fmt --check - - name: Run authority-precedence regression - run: cargo test --locked -p wardnet-reputation-core decision_envelope_accepts_authority_failure_reason_over_adverse_assessment -- --exact + - name: Test reputation core + run: cargo test --locked -p wardnet-reputation-core + - name: Clippy reputation core + run: cargo clippy --locked -p wardnet-reputation-core --all-targets -- -D warnings From 8e75415a1116691f329040c1d79f09f0b7f4dc8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:47:18 +0900 Subject: [PATCH 47/72] test(reputation): reject denial reasons on allow envelopes --- .../tests/decision_binding.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index 495bbb8..87abb4a 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -169,3 +169,32 @@ fn decision_envelope_accepts_authority_failure_reason_over_adverse_assessment() "higher-precedence required-authority failure must remain explainable without erasing the adverse assessment", ); } + +#[test] +fn decision_envelope_rejects_unknown_destination_reason_on_allow() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["action"] = json!("allow"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "unknown_destination describes a protect denial and must not validate as an allow reason" + ); +} + +#[test] +fn decision_envelope_rejects_invalid_contract_reason_on_allow() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["action"] = json!("allow"); + value["reason"] = json!("invalid_contract"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert!( + decision.validate().is_err(), + "invalid_contract is fail-closed and must never validate as an allow reason" + ); +} From e31f84040120e2a2ba72cc867ab67e270c288e0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:47:33 +0900 Subject: [PATCH 48/72] test(reputation): execute denial-reason RED --- .../reputation-authority-precedence-red.yml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/reputation-authority-precedence-red.yml b/.github/workflows/reputation-authority-precedence-red.yml index 7d432e2..11ba757 100644 --- a/.github/workflows/reputation-authority-precedence-red.yml +++ b/.github/workflows/reputation-authority-precedence-red.yml @@ -1,4 +1,4 @@ -name: Reputation authority precedence verification +name: Reputation decision reason RED on: pull_request: @@ -9,21 +9,19 @@ permissions: contents: read jobs: - verify: + red: runs-on: macos-15-arm64 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: e040201660c3a9d76deb91a81dd4c53b3ca9e5e2 + ref: 8e75415a1116691f329040c1d79f09f0b7f4dc8e fetch-depth: 1 - - name: Assert exact causal GREEN source - run: test "$(git rev-parse HEAD)" = "e040201660c3a9d76deb91a81dd4c53b3ca9e5e2" + - name: Assert exact hostile source + run: test "$(git rev-parse HEAD)" = "8e75415a1116691f329040c1d79f09f0b7f4dc8e" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable - name: Check formatting run: cargo fmt --check - - name: Test reputation core - run: cargo test --locked -p wardnet-reputation-core - - name: Clippy reputation core - run: cargo clippy --locked -p wardnet-reputation-core --all-targets -- -D warnings + - name: Run decision reason regressions + run: cargo test --locked -p wardnet-reputation-core --test decision_binding From 990410efbd3063a7184e5def0cb645065c406bee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:49:14 +0900 Subject: [PATCH 49/72] fix(reputation): bind allow actions to business authorization --- crates/wardnet-reputation-core/src/model.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index e2a2188..bc86842 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -367,6 +367,8 @@ pub enum DecisionReasonV1 { Suspicious, /// No adverse match exists, but the destination remains unknown without authorization. UnknownDestination, + /// An exact-scope business authorization permits an unknown destination to continue to other gates. + BusinessAuthorization, /// Required evidence or authority is unavailable or unverifiable. RequiredAuthorityUnavailable, /// Contract identity or version could not be validated. @@ -390,11 +392,20 @@ fn reason_matches_assessment( ReputationAssessmentV1::Suspicious => reason == DecisionReasonV1::Suspicious, ReputationAssessmentV1::Unknown => matches!( reason, - DecisionReasonV1::UnknownDestination | DecisionReasonV1::InvalidContract + DecisionReasonV1::UnknownDestination + | DecisionReasonV1::BusinessAuthorization + | DecisionReasonV1::InvalidContract ), } } +fn reason_matches_action(action: PolicyActionV1, reason: DecisionReasonV1) -> bool { + match action { + PolicyActionV1::Allow => reason == DecisionReasonV1::BusinessAuthorization, + PolicyActionV1::Deny => reason != DecisionReasonV1::BusinessAuthorization, + } +} + /// Explainable pure-core decision envelope; it is not proof that traffic was actually blocked. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DecisionEnvelopeV1 { @@ -438,6 +449,9 @@ impl DecisionEnvelopeV1 { if !reason_matches_assessment(self.assessment, self.evidence_health, self.reason) { return Err(ContractValidationErrorV1::InconsistentAssessmentReason); } + if !reason_matches_action(self.action, self.reason) { + return Err(ContractValidationErrorV1::InconsistentActionReason); + } let adverse_assessment = matches!( self.assessment, ReputationAssessmentV1::KnownMalicious | ReputationAssessmentV1::Suspicious @@ -491,6 +505,8 @@ pub enum ContractValidationErrorV1 { MissingDecisionEvidence, /// Decision assessment and machine-readable reason contradict each other. InconsistentAssessmentReason, + /// Decision action and machine-readable reason contradict each other. + InconsistentActionReason, /// An adverse assessment attempts to serialize as an allow action. UnsafeAdverseAllow, /// Expired or unavailable required evidence attempts to serialize as an allow action. From 94dc113b0b77033b695e61392197cfef2f325f21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:49:48 +0900 Subject: [PATCH 50/72] test(reputation): cover authorized unknown decision mapping --- .../tests/decision_binding.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index 87abb4a..a03600b 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -198,3 +198,46 @@ fn decision_envelope_rejects_invalid_contract_reason_on_allow() { "invalid_contract is fail-closed and must never validate as an allow reason" ); } + +#[test] +fn decision_envelope_accepts_business_authorization_allow_with_fresh_evidence() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["action"] = json!("allow"); + value["reason"] = json!("business_authorization"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + decision.validate().expect( + "unknown plus an exact-scope business authorization may continue when required evidence is fresh", + ); +} + +#[test] +fn decision_envelope_accepts_business_authorization_allow_with_optional_degradation() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["evidence_health"] = json!("degraded"); + value["action"] = json!("allow"); + value["reason"] = json!("business_authorization"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + decision.validate().expect( + "optional-source degradation may coexist with an authorized unknown allow while required sources remain healthy", + ); +} + +#[test] +fn decision_envelope_rejects_business_authorization_reason_on_deny() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["reason"] = json!("business_authorization"); + + let decision: DecisionEnvelopeV1 = + serde_json::from_value(value).expect("v1 decision envelope should deserialize"); + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::InconsistentActionReason) + ); +} From afbbd99751592ddfde1725d6af2a6ead8b194f16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 18:50:10 +0900 Subject: [PATCH 51/72] ci(reputation): verify decision mapping repair --- .../reputation-authority-precedence-red.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/reputation-authority-precedence-red.yml b/.github/workflows/reputation-authority-precedence-red.yml index 11ba757..ee10a6b 100644 --- a/.github/workflows/reputation-authority-precedence-red.yml +++ b/.github/workflows/reputation-authority-precedence-red.yml @@ -1,4 +1,4 @@ -name: Reputation decision reason RED +name: Reputation decision mapping verification on: pull_request: @@ -9,19 +9,21 @@ permissions: contents: read jobs: - red: + verify: runs-on: macos-15-arm64 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: 8e75415a1116691f329040c1d79f09f0b7f4dc8e + ref: 94dc113b0b77033b695e61392197cfef2f325f21 fetch-depth: 1 - - name: Assert exact hostile source - run: test "$(git rev-parse HEAD)" = "8e75415a1116691f329040c1d79f09f0b7f4dc8e" + - name: Assert exact causal source + run: test "$(git rev-parse HEAD)" = "94dc113b0b77033b695e61392197cfef2f325f21" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable - name: Check formatting run: cargo fmt --check - - name: Run decision reason regressions - run: cargo test --locked -p wardnet-reputation-core --test decision_binding + - name: Test reputation core + run: cargo test --locked -p wardnet-reputation-core + - name: Clippy reputation core + run: cargo clippy --locked -p wardnet-reputation-core --all-targets -- -D warnings From f89c22270738fc1727184e26bf756437cc08f252 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:04:27 +0900 Subject: [PATCH 52/72] ci(reputation): remove completed decision verification workflow --- .../reputation-authority-precedence-red.yml | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/reputation-authority-precedence-red.yml diff --git a/.github/workflows/reputation-authority-precedence-red.yml b/.github/workflows/reputation-authority-precedence-red.yml deleted file mode 100644 index ee10a6b..0000000 --- a/.github/workflows/reputation-authority-precedence-red.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Reputation decision mapping verification - -on: - pull_request: - paths: - - '.github/workflows/reputation-authority-precedence-red.yml' - -permissions: - contents: read - -jobs: - verify: - runs-on: macos-15-arm64 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: 94dc113b0b77033b695e61392197cfef2f325f21 - fetch-depth: 1 - - name: Assert exact causal source - run: test "$(git rev-parse HEAD)" = "94dc113b0b77033b695e61392197cfef2f325f21" - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Check formatting - run: cargo fmt --check - - name: Test reputation core - run: cargo test --locked -p wardnet-reputation-core - - name: Clippy reputation core - run: cargo clippy --locked -p wardnet-reputation-core --all-targets -- -D warnings From 73a20dd92f7eacca44f8f5cfb589293424f5824f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:14:42 +0900 Subject: [PATCH 53/72] test(reputation): pin fail-closed decision error precedence --- .../tests/error_precedence.rs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 crates/wardnet-reputation-core/tests/error_precedence.rs diff --git a/crates/wardnet-reputation-core/tests/error_precedence.rs b/crates/wardnet-reputation-core/tests/error_precedence.rs new file mode 100644 index 0000000..6e98f3c --- /dev/null +++ b/crates/wardnet-reputation-core/tests/error_precedence.rs @@ -0,0 +1,73 @@ +use wardnet_reputation_core::{ + ContractValidationErrorV1, DecisionEnvelopeV1, DecisionReasonV1, DestinationContextV1, + DestinationScopeV1, DestinationSubjectKindV1, DestinationSubjectV1, DirectionV1, + EvidenceHealthV1, PolicyActionV1, REPUTATION_SCHEMA_V1, ReputationAssessmentV1, +}; + +const NOW: u64 = 1_788_652_800; + +fn context() -> DestinationContextV1 { + DestinationContextV1 { + schema_version: REPUTATION_SCHEMA_V1.to_owned(), + direction: DirectionV1::Outbound, + tenant_id: "tenant-example".to_owned(), + workload_id: "workload-example".to_owned(), + purpose: "package_metadata".to_owned(), + operation_id: "op-error-precedence".to_owned(), + profile_id: "protect-default".to_owned(), + subject: DestinationSubjectV1 { + kind: DestinationSubjectKindV1::ExactHost, + value: "updates.example.invalid".to_owned(), + scope: DestinationScopeV1::Exact, + }, + canonicalization_profile: "egressweave-offline-fixture".to_owned(), + canonicalization_version: "1".to_owned(), + } +} + +fn base_decision() -> DecisionEnvelopeV1 { + DecisionEnvelopeV1 { + schema_version: REPUTATION_SCHEMA_V1.to_owned(), + evaluation_id: "eval-error-precedence".to_owned(), + context: context(), + policy_id: "protect-default".to_owned(), + policy_revision: 1, + evidence_generation: "snapshot-42".to_owned(), + assessment: ReputationAssessmentV1::Unknown, + evidence_health: EvidenceHealthV1::Fresh, + action: PolicyActionV1::Deny, + reason: DecisionReasonV1::UnknownDestination, + evaluated_at_unix: NOW, + expires_at_unix: NOW + 60, + evidence_refs: Vec::new(), + } +} + +#[test] +fn adverse_allow_returns_the_specific_fail_closed_error() { + let mut decision = base_decision(); + decision.assessment = ReputationAssessmentV1::KnownMalicious; + decision.action = PolicyActionV1::Allow; + decision.reason = DecisionReasonV1::KnownMalicious; + decision.evidence_refs = vec!["urn:wardnet:evidence:record-1".to_owned()]; + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::UnsafeAdverseAllow), + "an otherwise coherent adverse decision must classify the unsafe allow itself, not hide it behind a generic action/reason mismatch", + ); +} + +#[test] +fn unhealthy_required_authority_allow_returns_the_specific_fail_closed_error() { + let mut decision = base_decision(); + decision.evidence_health = EvidenceHealthV1::Unavailable; + decision.action = PolicyActionV1::Allow; + decision.reason = DecisionReasonV1::RequiredAuthorityUnavailable; + + assert_eq!( + decision.validate(), + Err(ContractValidationErrorV1::UnsafeUnhealthyEvidenceAllow), + "an otherwise coherent required-authority outage must classify the unsafe allow itself, not hide it behind a generic action/reason mismatch", + ); +} From dbb91e738e0478011ddb1372f644097a116adaa1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:14:53 +0900 Subject: [PATCH 54/72] ci(reputation): execute error-precedence RED on exact source --- .../reputation-error-precedence-red.yml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/reputation-error-precedence-red.yml diff --git a/.github/workflows/reputation-error-precedence-red.yml b/.github/workflows/reputation-error-precedence-red.yml new file mode 100644 index 0000000..877d48c --- /dev/null +++ b/.github/workflows/reputation-error-precedence-red.yml @@ -0,0 +1,31 @@ +name: Reputation error precedence causal verification + +on: + pull_request: + paths: + - '.github/workflows/reputation-error-precedence-red.yml' + - 'crates/wardnet-reputation-core/src/model.rs' + - 'crates/wardnet-reputation-core/tests/error_precedence.rs' + +permissions: + contents: read + +jobs: + verify: + runs-on: macos-15-arm64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + - name: Assert exact PR source + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Check formatting + run: cargo fmt --check + - name: Test reputation error precedence + run: cargo test --locked -p wardnet-reputation-core --test error_precedence From 68df34bb43cfa65abd8b89a3f81ebd35b860e4a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:20:09 +0900 Subject: [PATCH 55/72] test(reputation): pin provenance validation error --- crates/wardnet-reputation-core/tests/contract.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs index 54e571e..60b12a1 100644 --- a/crates/wardnet-reputation-core/tests/contract.rs +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -149,8 +149,9 @@ fn rejects_enforcement_evidence_without_provenance() { let mut candidate = evidence(); candidate.provenance_refs.clear(); - assert!( - candidate.validate_at(NOW).is_err(), + assert_eq!( + candidate.validate_at(NOW), + Err(ContractValidationErrorV1::MissingEnforcementProvenance), "enforcement-eligible evidence without provenance must fail closed" ); } From 62d17b916fa5d81f802ddbaa07acdd08e74d8829 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:20:52 +0900 Subject: [PATCH 56/72] docs(reputation): trace contract evidence rationale --- .../wardnet-reputation-core/TRACEABILITY.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 crates/wardnet-reputation-core/TRACEABILITY.md diff --git a/crates/wardnet-reputation-core/TRACEABILITY.md b/crates/wardnet-reputation-core/TRACEABILITY.md new file mode 100644 index 0000000..7a10cea --- /dev/null +++ b/crates/wardnet-reputation-core/TRACEABILITY.md @@ -0,0 +1,31 @@ +# Reputation contract research and standards traceability + +This note records the evidence boundary for `wardnet-reputation-core`. The crate defines transport-neutral Wardnet domain contracts; it is not an anomaly detector, feed client, executable egress authority, or transport policy engine. + +## Decision rationale + +NIST Cybersecurity Framework (CSF) 2.0 treats Detect as outcomes for finding and analyzing possible cybersecurity attacks and compromises while leaving implementation mechanisms to the adopting organization. Wardnet therefore keeps the observed security assessment, evidence-authority health, and policy action as separate fields instead of treating a detector output as self-executing authorization. + +Chandola, Banerjee, and Kumar (2009) show that anomaly-detection techniques depend on domain-specific assumptions about what distinguishes normal from anomalous behavior. That supports a conservative contract boundary here: absence of eligible adverse evidence is `unknown`, not evidence of benignness; producer confidence is retained as producer metadata rather than converted into a Wardnet probability; and an adverse classification remains traceable to reviewed evidence. The paper does **not** define Wardnet's `KnownMalicious`, `Suspicious`, or `Unknown` vocabulary. Those are Wardnet bounded-context terms for evidence state and must not be presented as categories from the paper. + +The v1 contract therefore chooses: + +- `KnownMalicious` only for eligible reviewed evidence that asserts a hard threat within explicit scope; +- `Suspicious` for adverse evidence that does not establish the hard-threat condition; +- `Unknown` when no eligible adverse match establishes safety; +- a separate `EvidenceHealthV1` so source outage or expiry can fail closed without rewriting the underlying assessment; +- a separate `PolicyActionV1`, because a Wardnet reputation allow only permits continuation to independent gates and is never executable EgressWeave transport authorization. + +Rejected alternatives are: treating `unknown` as benign, aggregating producer confidence into an invented probability, using HTTP success as an authorization signal, or allowing business authorization to override adverse evidence. These choices would erase provenance or conflate observation, policy, and enforcement authority. + +Load-balancing literature is intentionally not used to justify this contract shape. This crate performs no scheduling, dispatch, network I/O, or executable load balancing, so latency/implementation-overhead results from load-balancing systems are not causal evidence for the v1 data model. Performance and concurrency research becomes applicable when the evaluator/cache and measured deployment path are implemented; the implementation plan requires those later slices to profile and benchmark the real path rather than pre-justify this transport-neutral schema with unrelated systems results. + +## Source handling + +The ACM article is cited by DOI and bibliographic metadata only. Its publisher access is not assumed to grant redistribution rights, so no article PDF is copied into this repository. NIST CSF 2.0 is linked to its official NIST publication and DOI. Future local copies must be added only when redistribution terms are verified. + +## References + +Chandola, V., Banerjee, A., & Kumar, V. (2009). Anomaly detection: A survey. *ACM Computing Surveys, 41*(3), Article 15. https://doi.org/10.1145/1541880.1541882 + +Pascoe, C., Quinn, S., & Scarfone, K. (2024). *The NIST Cybersecurity Framework (CSF) 2.0* (NIST CSWP 29). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.CSWP.29 From a5a4a01836db1d0a126bbc4e500f8dbd2b71adda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:21:03 +0900 Subject: [PATCH 57/72] docs(reputation): link contract traceability from crate --- crates/wardnet-reputation-core/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/wardnet-reputation-core/src/lib.rs b/crates/wardnet-reputation-core/src/lib.rs index 2076a83..aa8c671 100644 --- a/crates/wardnet-reputation-core/src/lib.rs +++ b/crates/wardnet-reputation-core/src/lib.rs @@ -3,6 +3,9 @@ //! This crate deliberately performs no HTTP, DNS, transport authorization, database I/O, //! environment access, or LLM work. Executable outbound target interpretation remains an //! EgressWeave responsibility; this crate only accepts already-canonical offline descriptors. +//! +//! The research, standards, rejected alternatives, and evidence-handling rationale for this +//! contract boundary are recorded in the adjacent [TRACEABILITY.md](../TRACEABILITY.md). pub mod model; From 71cf87b01b3e15c9826638a7dcfd7d20b515b2a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:25:44 +0900 Subject: [PATCH 58/72] docs(reputation): complete production contract rustdoc --- crates/wardnet-reputation-core/src/model.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index bc86842..b4f1cc6 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -1,4 +1,8 @@ //! Versioned, transport-neutral outbound site-reputation domain contracts. +//! +//! Contract terminology, research/standards grounding, rejected alternatives, and the explicit +//! separation from executable transport authorization are recorded in +//! [TRACEABILITY.md](../TRACEABILITY.md). use serde::{Deserialize, Serialize}; @@ -8,6 +12,7 @@ pub const REPUTATION_SCHEMA_V1: &str = "wardnet.reputation.v1"; const MAX_TEXT_BYTES_V1: usize = 1_024; const MAX_LIST_ITEMS_V1: usize = 64; +/// Rejects contract schema identities outside the explicitly supported v1 family. fn validate_schema(schema_version: &str) -> Result<(), ContractValidationErrorV1> { if schema_version == REPUTATION_SCHEMA_V1 { Ok(()) @@ -16,6 +21,7 @@ fn validate_schema(schema_version: &str) -> Result<(), ContractValidationErrorV1 } } +/// Applies the shared nonblank and byte-bound contract to one required text field. fn validate_text(value: &str, field: &'static str) -> Result<(), ContractValidationErrorV1> { if value.trim().is_empty() { return Err(ContractValidationErrorV1::BlankField(field)); @@ -26,6 +32,7 @@ fn validate_text(value: &str, field: &'static str) -> Result<(), ContractValidat Ok(()) } +/// Applies required-text validation only when an optional producer field is present. fn validate_optional_text( value: Option<&str>, field: &'static str, @@ -36,6 +43,7 @@ fn validate_optional_text( Ok(()) } +/// Bounds a repeated text field before validating every element with the shared text contract. fn validate_text_list( values: &[String], field: &'static str, @@ -93,6 +101,7 @@ pub struct DestinationSubjectV1 { } impl DestinationSubjectV1 { + /// Rejects blank subjects and prevents subdomain scope from being attached to non-host kinds. fn validate(&self) -> Result<(), ContractValidationErrorV1> { validate_text(&self.value, "subject.value")?; if self.scope == DestinationScopeV1::HostAndSubdomains @@ -375,6 +384,7 @@ pub enum DecisionReasonV1 { InvalidContract, } +/// Checks that the reason describes the assessment, with authority-health failure taking precedence. fn reason_matches_assessment( assessment: ReputationAssessmentV1, evidence_health: EvidenceHealthV1, @@ -399,6 +409,7 @@ fn reason_matches_assessment( } } +/// Checks whether a machine-readable reason is coherent with the reputation-only policy action. fn reason_matches_action(action: PolicyActionV1, reason: DecisionReasonV1) -> bool { match action { PolicyActionV1::Allow => reason == DecisionReasonV1::BusinessAuthorization, From ffc5db29d4a2c68dbd6bf423e64ec78d6c298f3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:32:31 +0900 Subject: [PATCH 59/72] fix(reputation): preserve fail-closed error precedence --- crates/wardnet-reputation-core/src/model.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index b4f1cc6..eb756c1 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -460,9 +460,6 @@ impl DecisionEnvelopeV1 { if !reason_matches_assessment(self.assessment, self.evidence_health, self.reason) { return Err(ContractValidationErrorV1::InconsistentAssessmentReason); } - if !reason_matches_action(self.action, self.reason) { - return Err(ContractValidationErrorV1::InconsistentActionReason); - } let adverse_assessment = matches!( self.assessment, ReputationAssessmentV1::KnownMalicious | ReputationAssessmentV1::Suspicious @@ -480,6 +477,9 @@ impl DecisionEnvelopeV1 { if unhealthy_required_authority && self.action == PolicyActionV1::Allow { return Err(ContractValidationErrorV1::UnsafeUnhealthyEvidenceAllow); } + if !reason_matches_action(self.action, self.reason) { + return Err(ContractValidationErrorV1::InconsistentActionReason); + } if self.evaluated_at_unix > self.expires_at_unix { return Err(ContractValidationErrorV1::InvalidTimeOrder); } From 729c2213d32ff32884df7e70146800db0539085e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:45:00 +0900 Subject: [PATCH 60/72] test(reputation): reject scope-widening unknown evidence fields --- .../wardnet-reputation-core/tests/contract.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs index 60b12a1..734ddce 100644 --- a/crates/wardnet-reputation-core/tests/contract.rs +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use serde_json::json; use wardnet_reputation_core::{ ContractValidationErrorV1, DestinationContextV1, DestinationScopeV1, DestinationSubjectKindV1, DestinationSubjectV1, DirectionV1, EvaluationModeV1, EvidenceClassificationV1, @@ -156,6 +157,25 @@ fn rejects_enforcement_evidence_without_provenance() { ); } +#[test] +fn rejects_unknown_evidence_fields_that_could_widen_scope() { + let mut value = serde_json::to_value(evidence()).expect("evidence serializes"); + let object = value + .as_object_mut() + .expect("evidence contract serializes as an object"); + object.remove("tenant_id"); + object.insert( + "tenant_ids".to_string(), + json!(["tenant-example"]), + ); + + let decoded = serde_json::from_value::(value); + assert!( + decoded.is_err(), + "an unrecognized tenant restriction must fail closed instead of degrading to global evidence" + ); +} + #[test] fn rejects_empty_source_eligibility() { let mut candidate = source_policy(); From c9ee13b34fe9183302c5e7a87f97d0732c670ca6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:46:22 +0900 Subject: [PATCH 61/72] fix(reputation): reject unknown wire-contract fields --- crates/wardnet-reputation-core/src/model.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index eb756c1..56a932a 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -91,6 +91,7 @@ pub enum DestinationScopeV1 { /// Canonical destination descriptor that Wardnet matches without reparsing network syntax. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct DestinationSubjectV1 { /// Subject kind defining how the opaque canonical value may be matched. pub kind: DestinationSubjectKindV1, @@ -115,6 +116,7 @@ impl DestinationSubjectV1 { /// Authenticated evaluation context after identity claims have been verified by the service edge. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct DestinationContextV1 { /// Contract schema identifier. pub schema_version: String, @@ -169,6 +171,7 @@ pub enum EvidenceClassificationV1 { /// Versioned source evidence retained with lifecycle and provenance semantics. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct EvidenceRecordV1 { /// Contract schema identifier. pub schema_version: String, @@ -244,6 +247,7 @@ impl EvidenceRecordV1 { /// Policy attached to one reviewed evidence source. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct SourcePolicyV1 { /// Contract schema identifier. pub schema_version: String, @@ -289,6 +293,7 @@ pub enum EvaluationModeV1 { /// Immutable reputation policy revision consumed by the pure core. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct PolicySnapshotV1 { /// Contract schema identifier. pub schema_version: String, @@ -374,7 +379,7 @@ pub enum DecisionReasonV1 { KnownMalicious, /// Suspicious evidence caused the initial protect profile to deny. Suspicious, - /// No adverse match exists, but the destination remains unknown without authorization. + /// No active eligible adverse match exists, and no exact-scope authorization applies. UnknownDestination, /// An exact-scope business authorization permits an unknown destination to continue to other gates. BusinessAuthorization, @@ -419,6 +424,7 @@ fn reason_matches_action(action: PolicyActionV1, reason: DecisionReasonV1) -> bo /// Explainable pure-core decision envelope; it is not proof that traffic was actually blocked. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct DecisionEnvelopeV1 { /// Contract schema identifier. pub schema_version: String, From 9994948d8436cdd9aab860a32bc051fafce29650 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:47:55 +0900 Subject: [PATCH 62/72] docs(reputation): trace fail-closed wire validation --- crates/wardnet-reputation-core/TRACEABILITY.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/wardnet-reputation-core/TRACEABILITY.md b/crates/wardnet-reputation-core/TRACEABILITY.md index 7a10cea..7f8868d 100644 --- a/crates/wardnet-reputation-core/TRACEABILITY.md +++ b/crates/wardnet-reputation-core/TRACEABILITY.md @@ -20,12 +20,28 @@ Rejected alternatives are: treating `unknown` as benign, aggregating producer co Load-balancing literature is intentionally not used to justify this contract shape. This crate performs no scheduling, dispatch, network I/O, or executable load balancing, so latency/implementation-overhead results from load-balancing systems are not causal evidence for the v1 data model. Performance and concurrency research becomes applicable when the evaluator/cache and measured deployment path are implemented; the implementation plan requires those later slices to profile and benchmark the real path rather than pre-justify this transport-neutral schema with unrelated systems results. +## Wire-schema compatibility and fail-closed decoding + +The v1 wire structs accept only fields declared by the exact `wardnet.reputation.v1` schema. Serde's default forward-compatible behavior of ignoring unknown struct fields is deliberately rejected at this security boundary. An unrecognized scope-bearing field can otherwise combine with an omitted optional field and change semantics. The hostile regression demonstrates the concrete case: `tenant_ids` would be ignored while absent `tenant_id` deserializes as `None`, and `None` is the contract state that may represent globally applicable evidence. That is a scope-widening failure, not harmless extension data. + +MITRE CWE-20 explicitly calls out missing and extra inputs as properties that input validation should consider and recommends accepting only values that strictly conform to the intended specification. NIST SP 800-218 SSDF 1.1 PW.5.1 likewise includes validating all inputs as a secure-coding implementation example. Accordingly, every v1 wire struct uses strict unknown-field rejection. A producer or consumer that needs a new security-relevant field must negotiate a new compatible schema version instead of relying on a v1 reader to discard it. + +The rejected alternative was permissive unknown-field decoding for forward compatibility. It was rejected because this contract contains optional scope, marking, and evidence metadata whose absence has domain meaning; silently discarding a misspelled or future field can therefore convert an explicit restriction into absence. Exact versioning is the safer and more reviewable compatibility mechanism. + +As of 2026-09-06, NIST SP 800-218 Rev. 1 / SSDF 1.2 is still an Initial Public Draft rather than a final replacement. It is tracked for forward awareness, while the final SSDF 1.1 remains the normative NIST citation used for this implemented decision. + ## Source handling -The ACM article is cited by DOI and bibliographic metadata only. Its publisher access is not assumed to grant redistribution rights, so no article PDF is copied into this repository. NIST CSF 2.0 is linked to its official NIST publication and DOI. Future local copies must be added only when redistribution terms are verified. +The ACM article is cited by DOI and bibliographic metadata only. Its publisher access is not assumed to grant redistribution rights, so no article PDF is copied into this repository. NIST CSF 2.0 and SSDF 1.1 are linked to official NIST publications. Future local copies must be added only when redistribution terms are verified. ## References Chandola, V., Banerjee, A., & Kumar, V. (2009). Anomaly detection: A survey. *ACM Computing Surveys, 41*(3), Article 15. https://doi.org/10.1145/1541880.1541882 +MITRE. (n.d.). *CWE-20: Improper input validation* (Version 4.20). Retrieved September 6, 2026, from https://cwe.mitre.org/data/definitions/20.html + Pascoe, C., Quinn, S., & Scarfone, K. (2024). *The NIST Cybersecurity Framework (CSF) 2.0* (NIST CSWP 29). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.CSWP.29 + +Souppaya, M., & Scarfone, K. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd From 4f5a9ddf412b57bd76cb50562b464937bda7fce7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:50:18 +0900 Subject: [PATCH 63/72] test(reputation): cover strict v1 wire structs --- .../wardnet-reputation-core/tests/contract.rs | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs index 734ddce..8ea3978 100644 --- a/crates/wardnet-reputation-core/tests/contract.rs +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::json; use wardnet_reputation_core::{ ContractValidationErrorV1, DestinationContextV1, DestinationScopeV1, DestinationSubjectKindV1, @@ -66,6 +66,21 @@ fn evidence() -> EvidenceRecordV1 { } } +fn assert_unknown_field_rejected(candidate: T, field: &str) +where + T: Serialize + DeserializeOwned, +{ + let mut value = serde_json::to_value(candidate).expect("contract serializes"); + value + .as_object_mut() + .expect("wire contract serializes as an object") + .insert(field.to_string(), json!(true)); + assert!( + serde_json::from_value::(value).is_err(), + "v1 wire contract must reject unknown field {field}" + ); +} + #[test] fn rejects_wrong_direction_and_unknown_schema() { let mut candidate = context(); @@ -164,10 +179,7 @@ fn rejects_unknown_evidence_fields_that_could_widen_scope() { .as_object_mut() .expect("evidence contract serializes as an object"); object.remove("tenant_id"); - object.insert( - "tenant_ids".to_string(), - json!(["tenant-example"]), - ); + object.insert("tenant_ids".to_string(), json!(["tenant-example"])); let decoded = serde_json::from_value::(value); assert!( @@ -176,6 +188,26 @@ fn rejects_unknown_evidence_fields_that_could_widen_scope() { ); } +#[test] +fn rejects_unknown_fields_across_nondecision_v1_wire_structs() { + assert_unknown_field_rejected(subject(), "unexpected_subject_field"); + assert_unknown_field_rejected(context(), "caller_authenticated"); + assert_unknown_field_rejected(evidence(), "unexpected_evidence_scope"); + assert_unknown_field_rejected(source_policy(), "fallback_allow"); + assert_unknown_field_rejected( + PolicySnapshotV1 { + schema_version: REPUTATION_SCHEMA_V1.to_string(), + policy_id: "protect-default".to_string(), + revision: 1, + mode: EvaluationModeV1::Protect, + required_sources: vec!["reviewed-source".to_string()], + valid_from_unix: NOW - 60, + valid_until_unix: NOW + 600, + }, + "unknown_policy_extension", + ); +} + #[test] fn rejects_empty_source_eligibility() { let mut candidate = source_policy(); From a7e1bbd1c1bae3f01ae015196f9cecf5a759f9da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:50:57 +0900 Subject: [PATCH 64/72] test(reputation): reject unknown decision fields --- .../wardnet-reputation-core/tests/decision_binding.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/decision_binding.rs b/crates/wardnet-reputation-core/tests/decision_binding.rs index a03600b..aeb3a36 100644 --- a/crates/wardnet-reputation-core/tests/decision_binding.rs +++ b/crates/wardnet-reputation-core/tests/decision_binding.rs @@ -38,6 +38,17 @@ fn decision_json(workload_id: &str, evidence_generation: &str) -> Value { }) } +#[test] +fn decision_envelope_rejects_unknown_wire_fields() { + let mut value = decision_json("workload-example", "snapshot-42"); + value["transport_authorized"] = json!(true); + + assert!( + serde_json::from_value::(value).is_err(), + "v1 decision readers must reject unknown transport or authorization claims instead of ignoring them" + ); +} + #[test] fn decision_envelope_rejects_invalid_authenticated_context_binding() { let decision: DecisionEnvelopeV1 = serde_json::from_value(decision_json("", "snapshot-42")) From 305308a2c009ba2ef46d2cd1c432693f56c35c2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:52:29 +0900 Subject: [PATCH 65/72] style(reputation): keep contract test imports rustfmt-stable From d4b84d8f6c8f7df1d59659f1f7b079a6ca1b52e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:53:17 +0900 Subject: [PATCH 66/72] style(reputation): normalize serde test imports From c9a02378705623b934e2d4f05743c74c36e5026d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:11:48 +0900 Subject: [PATCH 67/72] test(reputation): require explicit tenant source eligibility --- .../wardnet-reputation-core/tests/contract.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs index 8ea3978..01fe7b4 100644 --- a/crates/wardnet-reputation-core/tests/contract.rs +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -225,6 +225,21 @@ fn rejects_empty_source_eligibility() { ); } +#[test] +fn rejects_source_policy_without_explicit_tenant_eligibility() { + let mut value = serde_json::to_value(source_policy()).expect("source policy serializes"); + let object = value + .as_object_mut() + .expect("source policy serializes as an object"); + object.remove("tenant_scope"); + object.remove("allowed_tenant_ids"); + + assert!( + serde_json::from_value::(value).is_err(), + "a reviewed source must declare tenant eligibility explicitly instead of silently widening to every tenant" + ); +} + #[derive(Debug, Deserialize)] struct ContractFixture { case_id: String, @@ -265,4 +280,4 @@ fn exact_host_fixture_round_trips_stably() { let decoded: DestinationContextV1 = serde_json::from_str(&encoded).expect("context deserializes"); assert_eq!(decoded, fixture.context); -} +} \ No newline at end of file From 1f97070eb7a9b2362a3c58ca32650650b4a21aac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:13:50 +0900 Subject: [PATCH 68/72] fix(reputation): bind reviewed sources to explicit tenant eligibility --- crates/wardnet-reputation-core/src/model.rs | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index 56a932a..2d97d66 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -245,6 +245,16 @@ impl EvidenceRecordV1 { } } +/// Reviewed tenant-eligibility semantics for one reputation evidence source. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceTenantScopeV1 { + /// The reviewed source may contribute for any authenticated tenant. + AllAuthenticatedTenants, + /// The reviewed source may contribute only for an explicit bounded tenant set. + ExplicitTenantSet, +} + /// Policy attached to one reviewed evidence source. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -259,6 +269,10 @@ pub struct SourcePolicyV1 { pub permitted_subject_kinds: Vec, /// Maximum evidence age allowed by Wardnet policy, in seconds. pub max_evidence_age_seconds: u64, + /// Explicit reviewed tenant-scope mode; omission is invalid on the v1 wire. + pub tenant_scope: SourceTenantScopeV1, + /// Bounded tenant identifiers when `tenant_scope` is `explicit_tenant_set`. + pub allowed_tenant_ids: Vec, /// Purposes for which this source may contribute; empty is invalid. pub allowed_purposes: Vec, } @@ -276,6 +290,16 @@ impl SourcePolicyV1 { "permitted_subject_kinds", )); } + validate_text_list(&self.allowed_tenant_ids, "allowed_tenant_ids")?; + match self.tenant_scope { + SourceTenantScopeV1::AllAuthenticatedTenants if !self.allowed_tenant_ids.is_empty() => { + return Err(ContractValidationErrorV1::InvalidTenantEligibility); + } + SourceTenantScopeV1::ExplicitTenantSet if self.allowed_tenant_ids.is_empty() => { + return Err(ContractValidationErrorV1::InvalidTenantEligibility); + } + _ => {} + } validate_text_list(&self.allowed_purposes, "allowed_purposes")?; Ok(()) } @@ -516,6 +540,8 @@ pub enum ContractValidationErrorV1 { InvalidConfidence, /// A source policy does not permit any subject kind or purpose. EmptySourceEligibility, + /// A source policy tenant-scope mode contradicts its explicit tenant set. + InvalidTenantEligibility, /// Evidence eligible for enforcement has no provenance reference. MissingEnforcementProvenance, /// An adverse decision assessment has no evidence reference for SOC traceability. @@ -528,4 +554,4 @@ pub enum ContractValidationErrorV1 { UnsafeAdverseAllow, /// Expired or unavailable required evidence attempts to serialize as an allow action. UnsafeUnhealthyEvidenceAllow, -} +} \ No newline at end of file From 52fbb803ca57feb893339b8c164086ebdcfc425b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:14:38 +0900 Subject: [PATCH 69/72] test(reputation): cover tenant eligibility invariants --- .../wardnet-reputation-core/tests/contract.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs index 01fe7b4..beb127e 100644 --- a/crates/wardnet-reputation-core/tests/contract.rs +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -4,6 +4,7 @@ use wardnet_reputation_core::{ ContractValidationErrorV1, DestinationContextV1, DestinationScopeV1, DestinationSubjectKindV1, DestinationSubjectV1, DirectionV1, EvaluationModeV1, EvidenceClassificationV1, EvidenceRecordV1, PolicySnapshotV1, REPUTATION_SCHEMA_V1, SourcePolicyV1, + SourceTenantScopeV1, }; const NOW: u64 = 1_788_652_800; @@ -38,6 +39,8 @@ fn source_policy() -> SourcePolicyV1 { enforcement_capable: true, permitted_subject_kinds: vec![DestinationSubjectKindV1::ExactHost], max_evidence_age_seconds: 3_600, + tenant_scope: SourceTenantScopeV1::ExplicitTenantSet, + allowed_tenant_ids: vec!["tenant-example".to_string()], allowed_purposes: vec!["package_metadata".to_string()], } } @@ -240,6 +243,34 @@ fn rejects_source_policy_without_explicit_tenant_eligibility() { ); } +#[test] +fn rejects_ambiguous_source_tenant_eligibility() { + let mut candidate = source_policy(); + candidate.tenant_scope = SourceTenantScopeV1::ExplicitTenantSet; + candidate.allowed_tenant_ids.clear(); + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::InvalidTenantEligibility) + ); + + let mut candidate = source_policy(); + candidate.tenant_scope = SourceTenantScopeV1::AllAuthenticatedTenants; + assert_eq!( + candidate.validate(), + Err(ContractValidationErrorV1::InvalidTenantEligibility) + ); +} + +#[test] +fn accepts_reviewed_all_authenticated_tenant_scope_without_tenant_list() { + let mut candidate = source_policy(); + candidate.tenant_scope = SourceTenantScopeV1::AllAuthenticatedTenants; + candidate.allowed_tenant_ids.clear(); + candidate + .validate() + .expect("explicit reviewed all-tenant eligibility remains a valid source policy"); +} + #[derive(Debug, Deserialize)] struct ContractFixture { case_id: String, From 8121000b77a627b68c6e781aeb8dbd70c6dbd87a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:14:46 +0900 Subject: [PATCH 70/72] test(reputation): bind fixture source to tenant scope --- tests/fixtures/reputation/v1/exact_host_roundtrip.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fixtures/reputation/v1/exact_host_roundtrip.json b/tests/fixtures/reputation/v1/exact_host_roundtrip.json index f93c419..92d0485 100644 --- a/tests/fixtures/reputation/v1/exact_host_roundtrip.json +++ b/tests/fixtures/reputation/v1/exact_host_roundtrip.json @@ -23,6 +23,8 @@ "enforcement_capable": true, "permitted_subject_kinds": ["exact_host"], "max_evidence_age_seconds": 3600, + "tenant_scope": "explicit_tenant_set", + "allowed_tenant_ids": ["tenant-example"], "allowed_purposes": ["package_metadata"] }, "evidence": [], From cba547dcaadb8a6868de44604c28d7dcd4d14244 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 21:15:07 +0900 Subject: [PATCH 71/72] docs(reputation): trace tenant eligibility fail-closed boundary --- crates/wardnet-reputation-core/TRACEABILITY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/wardnet-reputation-core/TRACEABILITY.md b/crates/wardnet-reputation-core/TRACEABILITY.md index 7f8868d..a6e8d8d 100644 --- a/crates/wardnet-reputation-core/TRACEABILITY.md +++ b/crates/wardnet-reputation-core/TRACEABILITY.md @@ -24,6 +24,8 @@ Load-balancing literature is intentionally not used to justify this contract sha The v1 wire structs accept only fields declared by the exact `wardnet.reputation.v1` schema. Serde's default forward-compatible behavior of ignoring unknown struct fields is deliberately rejected at this security boundary. An unrecognized scope-bearing field can otherwise combine with an omitted optional field and change semantics. The hostile regression demonstrates the concrete case: `tenant_ids` would be ignored while absent `tenant_id` deserializes as `None`, and `None` is the contract state that may represent globally applicable evidence. That is a scope-widening failure, not harmless extension data. +The same rule applies to reviewed source eligibility. A source policy must now state whether it is reviewed for `all_authenticated_tenants` or an `explicit_tenant_set`; an omitted tenant-scope declaration is invalid, an explicit set cannot be empty, and an all-tenant declaration cannot smuggle a contradictory tenant list. This closes the gap between authenticated tenant binding and source eligibility without treating a missing restriction as an implicit organization-wide grant. It also keeps the tenant decision inside Wardnet's reputation-policy contract rather than moving identity authentication into this crate. + MITRE CWE-20 explicitly calls out missing and extra inputs as properties that input validation should consider and recommends accepting only values that strictly conform to the intended specification. NIST SP 800-218 SSDF 1.1 PW.5.1 likewise includes validating all inputs as a secure-coding implementation example. Accordingly, every v1 wire struct uses strict unknown-field rejection. A producer or consumer that needs a new security-relevant field must negotiate a new compatible schema version instead of relying on a v1 reader to discard it. The rejected alternative was permissive unknown-field decoding for forward compatibility. It was rejected because this contract contains optional scope, marking, and evidence metadata whose absence has domain meaning; silently discarding a misspelled or future field can therefore convert an explicit restriction into absence. Exact versioning is the safer and more reviewable compatibility mechanism. From 45f2aecd40983b785c1e37489596af641dedcc84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:06:08 +0900 Subject: [PATCH 72/72] style(reputation): apply rustfmt to contract core --- crates/wardnet-reputation-core/src/model.rs | 2 +- crates/wardnet-reputation-core/tests/contract.rs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/wardnet-reputation-core/src/model.rs b/crates/wardnet-reputation-core/src/model.rs index 2d97d66..718c528 100644 --- a/crates/wardnet-reputation-core/src/model.rs +++ b/crates/wardnet-reputation-core/src/model.rs @@ -554,4 +554,4 @@ pub enum ContractValidationErrorV1 { UnsafeAdverseAllow, /// Expired or unavailable required evidence attempts to serialize as an allow action. UnsafeUnhealthyEvidenceAllow, -} \ No newline at end of file +} diff --git a/crates/wardnet-reputation-core/tests/contract.rs b/crates/wardnet-reputation-core/tests/contract.rs index beb127e..b8c83d9 100644 --- a/crates/wardnet-reputation-core/tests/contract.rs +++ b/crates/wardnet-reputation-core/tests/contract.rs @@ -3,8 +3,7 @@ use serde_json::json; use wardnet_reputation_core::{ ContractValidationErrorV1, DestinationContextV1, DestinationScopeV1, DestinationSubjectKindV1, DestinationSubjectV1, DirectionV1, EvaluationModeV1, EvidenceClassificationV1, - EvidenceRecordV1, PolicySnapshotV1, REPUTATION_SCHEMA_V1, SourcePolicyV1, - SourceTenantScopeV1, + EvidenceRecordV1, PolicySnapshotV1, REPUTATION_SCHEMA_V1, SourcePolicyV1, SourceTenantScopeV1, }; const NOW: u64 = 1_788_652_800; @@ -311,4 +310,4 @@ fn exact_host_fixture_round_trips_stably() { let decoded: DestinationContextV1 = serde_json::from_str(&encoded).expect("context deserializes"); assert_eq!(decoded, fixture.context); -} \ No newline at end of file +}