From c94c53259dbe27279488ae786f9bb9d66ff0d6ac Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 05:27:44 -0400 Subject: [PATCH] jouleclaw-compliance: a scoped PLD disclosure bundle, and a crosswalk to the provisions Directive (EU) 2024/2853 applies to products placed on the market after 9 December 2026 (Art. 2(1)). A court may order a defendant to disclose relevant evidence (Art. 9(1)), limited to what is necessary and proportionate (9(3)), with regard to trade secrets and confidential information (9(4), 9(5)), and presented in an easily understandable manner (9(6)). A failure to disclose is presumed to mean a defective product (Art. 10(2)(a)). Nothing in the tree produced such a disclosure. product_liability.rs, beside cra.rs: - DisclosureBundle discloses one contiguous range of a transparency log whose leaves are records' BLAKE3 content hashes. Every record in the range is a member: disclosed with its bytes, or withheld with its content hash and a Ground (trade secret, confidential, third party, not necessary), each citing its paragraph. Each member carries an inclusion proof against one signed tree head, and the bundle can carry a consistency proof from an earlier head the receiving party holds. - verify establishes, from the log's key alone: - the head is signed; - the members are the range exactly, with nothing missing, repeated or reordered; - each member is the leaf at its index; - the log grew from the held head by appending alone. So a record in scope cannot be left out unseen. Member::opened_by checks a later court-ordered production of a withheld record against the hash that pinned it. - cut builds a bundle from any ProvableLog (MerkleTree, or a CRA FilingChain) and verifies it before returning it. - plain_index gives a line per member for Art. 9(6). - crosswalk lists the provisions records bear on: Art. 10(2)(a), (b), (c); 10(3); 10(4); and Art. 11(2)(b)-(d) with 4(18) and 7(2)(c), (e) for model changes. bearing_on picks out a bundle's members for each. It is framed as evidence a party can produce and check; Art. 10(5) leaves rebuttal to the court. The Directive's text was checked against the Official Journal (the Publications Office XHTML) for every article cited. Named product_liability rather than pld because jouleclaw-runtime already has a pld.rs (prompt lookup decoding). Tests: 8 new. Among them, a crafted inclusion proof that is valid against the root and is refused by the tree-size check. Under a 3-leaf root, leaf 2 proves as "leaf 1 of a 2-leaf tree", which would let it stand in for leaf 1. Mutation testing: 17 mutants, all killed. Three survived the first round (the tree-size check, the consistency proof's size match, and the offer count at the cut) and are killed by the tests added for them. Verified on the build host: jouleclaw-compliance 84 tests under --locked (no dependents); clippy -D warnings and rustdoc -D warnings (missing_docs) are clean; the public-surface scan passes. Repo checks pass. Co-Authored-By: Claude Opus 5.5 --- .../crates/jouleclaw-compliance/Cargo.toml | 2 - .../crates/jouleclaw-compliance/src/lib.rs | 6 + .../src/product_liability.rs | 758 ++++++++++++++++++ 3 files changed, 764 insertions(+), 2 deletions(-) create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/product_liability.rs diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml index 71fc842e6..baae8d262 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml @@ -18,6 +18,4 @@ serde_json = { workspace = true } thiserror.workspace = true chrono = { workspace = true } blake3 = { workspace = true } - -[dev-dependencies] ed25519-dalek = { workspace = true } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs index edb4b8dd4..9ea617b23 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs @@ -17,6 +17,11 @@ //! warning, notification and final report as a hash-linked filing chain, //! each filing a leaf in a Merkle log with an inclusion-proof receipt, and //! each stage's statutory deadline read off the chain. See [`cra`]. +//! - **EU Product Liability Directive disclosure** — one contiguous range of a +//! transparency log disclosed as a bundle, every record in it disclosed or +//! withheld on a stated ground, each proven against a signed head, with a +//! crosswalk from its records to the provisions they bear on. See +//! [`product_liability`]. //! //! Every passport links a [`passport::MeasurementMethod`] to a //! [`jouleclaw_energy::Provenance`] tag — measurement honesty travels @@ -45,6 +50,7 @@ pub mod banding; pub mod passport; pub mod eu_compliance; pub mod cra; +pub mod product_liability; pub use cra::{ Art14Subject, ChainError, DeadlineState, FiledReceipt, Filing, FilingChain, FilingStage, diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/product_liability.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/product_liability.rs new file mode 100644 index 000000000..f3103c98a --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/product_liability.rs @@ -0,0 +1,758 @@ +//! EU Product Liability Directive: a scoped disclosure bundle over a +//! transparency log, and a crosswalk from its records to the provisions they +//! bear on. +//! +//! Directive (EU) 2024/2853 applies to products placed on the market or put +//! into service after 9 December 2026 (Art. 2(1)). Where a claimant has +//! presented facts and evidence sufficient to support the plausibility of a +//! claim, a national court may require the defendant to disclose relevant +//! evidence at its disposal (Art. 9(1)). Disclosure is limited to what is +//! necessary and proportionate (Art. 9(3)). Courts weigh the legitimate +//! interests of all parties, in particular the protection of confidential +//! information and trade secrets (Art. 9(4)), and may take measures to +//! preserve a trade secret's confidentiality (Art. 9(5)). They may require +//! evidence to be presented in an easily accessible and easily understandable +//! manner (Art. 9(6)). A defendant that fails to disclose relevant evidence +//! under Art. 9(1) is presumed to have supplied a defective product (Art. +//! 10(2)(a)). +//! +//! ## The bundle +//! +//! A [`DisclosureBundle`] discloses one contiguous range of an append-only +//! Merkle log whose leaves are the BLAKE3 content hashes of records. It holds +//! every record in the range, and leaves none out. Each [`Member`] is either +//! disclosed, with its bytes, or withheld, with its content hash and the +//! [`Ground`] it is withheld on. Each carries an inclusion proof against a +//! signed tree head. +//! +//! [`DisclosureBundle::verify`] establishes, from the bundle and the log's +//! public key alone: +//! +//! - the head is signed by the log's key; +//! - the members are the range exactly: every index from `start` to `end`, in +//! order, none missing and none repeated; +//! - each member is the leaf at its index: disclosed bytes hash to it, and a +//! withheld member's hash is it; +//! - where the verifying party holds an earlier signed head, the log grew from +//! it only by appending. +//! +//! A bundle therefore cannot leave a record in its scope out unseen. A record +//! not disclosed is listed as withheld, under a stated ground, and its content +//! hash fixes what a later order to disclose it must produce +//! ([`Member::opened_by`]). +//! +//! It does not establish that the scope is the one a court would set (Art. +//! 9(3) is the court's call), that a withheld record is in fact a trade +//! secret, or anything about defectiveness. +//! +//! ## The crosswalk +//! +//! [`crosswalk`] lists the provisions such records bear on, and which kinds of +//! record bear on each. It is a reading aid. It says what a party can produce +//! and check, and not what follows from it: the defendant may rebut any of +//! the presumptions (Art. 10(5)), and whether any record establishes or rebuts +//! one is for the court. + +use std::fmt::Write as _; + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use jouleclaw_transparency_log::{ + ConsistencyProof, InclusionProof, LogError, MerkleTree, SignedTreeHead, TreeHead, +}; +use serde::{Deserialize, Serialize}; + +use crate::cra::FilingChain; + +/// The day from which the Directive applies to products placed on the market +/// or put into service (Art. 2(1): products placed after 9 December 2026). +pub const APPLIES_TO_PRODUCTS_PLACED_AFTER: &str = "2026-12-09"; + +/// What a record is, as its producer labels it. The label is the producer's +/// statement; a bundle verifies the record's bytes, not its label. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EvidenceKind { + /// A signed record of one unit of work: what was asked, on what, under + /// which authority, at what cost. + WorkReceipt, + /// An output with what opens its receipt's commitment to it. + OutputDisclosure, + /// The result of re-executing a recorded unit of work. + ReplayVerdict, + /// An entry of a model change record: a change of weights, or a plan for + /// changes recorded before them. + ModelChange, + /// A filing under Regulation (EU) 2024/2847, Art. 14 (see [`crate::cra`]). + VulnerabilityOrIncidentFiling, + /// A conformance or validation result. + ConformanceResult, + /// The grant or policy under which work was authorised. + Authority, + /// Anything else, as the producer names it. + Other(String), +} + +impl EvidenceKind { + /// The kind in plain words. + pub fn describe(&self) -> &str { + match self { + Self::WorkReceipt => "work receipt", + Self::OutputDisclosure => "output disclosure", + Self::ReplayVerdict => "replay verdict", + Self::ModelChange => "model change record", + Self::VulnerabilityOrIncidentFiling => "vulnerability or incident filing", + Self::ConformanceResult => "conformance result", + Self::Authority => "authority", + Self::Other(s) => s, + } + } +} + +/// Why a record in a bundle's scope is withheld rather than disclosed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Ground { + /// A trade secret within Art. 4(17) (Directive (EU) 2016/943, Art. 2(1)), + /// for the court to weigh under Art. 9(4) and protect under Art. 9(5). + TradeSecret, + /// Other confidential information, for the court to weigh under Art. 9(4). + Confidential, + /// The legitimate interests of a third party, such as another person's + /// records, for the court to weigh under Art. 9(4). + ThirdParty, + /// Beyond what is necessary and proportionate to the claim (Art. 9(3)). + NotNecessary, +} + +impl Ground { + /// The ground in plain words, with the provision it rests on. + pub fn describe(self) -> &'static str { + match self { + Self::TradeSecret => "trade secret (Art. 9(4), 9(5))", + Self::Confidential => "confidential information (Art. 9(4))", + Self::ThirdParty => "a third party's interests (Art. 9(4))", + Self::NotNecessary => "not necessary and proportionate (Art. 9(3))", + } + } +} + +/// A member's content: the record itself, or what pins it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Content { + /// The record's bytes. + Disclosed(Vec), + /// The record's BLAKE3 content hash, and why it is withheld. + Withheld { + /// The BLAKE3 hash of the withheld record's bytes: the log's leaf. + content_hash: [u8; 32], + /// Why it is withheld. + ground: Ground, + }, +} + +/// One record of a bundle's range. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Member { + /// Its leaf index in the log. + pub index: u64, + /// What it is, as its producer labels it. + pub kind: EvidenceKind, + /// A plain description of it (Art. 9(6)). + pub label: String, + /// The record, or what pins it. + pub content: Content, + /// That its content hash is the leaf at `index` under the bundle's head. + pub proof: InclusionProof, +} + +impl Member { + /// The content hash this member stands for: the log's leaf at its index. + pub fn content_hash(&self) -> [u8; 32] { + match &self.content { + Content::Disclosed(bytes) => *blake3::hash(bytes).as_bytes(), + Content::Withheld { content_hash, .. } => *content_hash, + } + } + + /// Whether `bytes` are the record this member stands for: how bytes + /// produced later, under an order to disclose a withheld record, are + /// checked against the bundle that withheld them. + pub fn opened_by(&self, bytes: &[u8]) -> bool { + blake3::hash(bytes).as_bytes() == &self.content_hash() + } + + /// The ground it is withheld on, or `None` when it is disclosed. + pub fn withheld_on(&self) -> Option { + match self.content { + Content::Disclosed(_) => None, + Content::Withheld { ground, .. } => Some(ground), + } + } +} + +/// A member as its producer offers it, before it has a proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Offer { + /// What it is. + pub kind: EvidenceKind, + /// A plain description of it. + pub label: String, + /// The record, or what pins it. + pub content: Content, +} + +/// A log a bundle can be cut from: one whose leaves are the BLAKE3 content +/// hashes of its records. +pub trait ProvableLog { + /// The log's head now, for its operator to sign. + fn head(&self, timestamp_unix: u64) -> TreeHead; + /// A proof that leaf `index` is in the log under its current root. + fn inclusion_proof(&self, index: u64) -> Result; + /// A proof that the log as it stood at `old_size` is a prefix of it now. + fn consistency_proof(&self, old_size: u64) -> Result; +} + +impl ProvableLog for MerkleTree { + fn head(&self, timestamp_unix: u64) -> TreeHead { + TreeHead::of(self, timestamp_unix) + } + fn inclusion_proof(&self, index: u64) -> Result { + MerkleTree::inclusion_proof(self, index) + } + fn consistency_proof(&self, old_size: u64) -> Result { + MerkleTree::consistency_proof(self, old_size) + } +} + +impl ProvableLog for FilingChain { + fn head(&self, timestamp_unix: u64) -> TreeHead { + self.tree_head(timestamp_unix) + } + fn inclusion_proof(&self, index: u64) -> Result { + FilingChain::inclusion_proof(self, index) + } + fn consistency_proof(&self, old_size: u64) -> Result { + FilingChain::consistency_proof(self, old_size) + } +} + +/// Why a bundle cannot be cut, or does not verify. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DisclosureError { + /// The bundle's head does not verify under the log's key. + #[error("the tree head does not verify: {0}")] + Head(String), + /// The range is empty or runs past the log. + #[error("the range {start}..{end} is not a non-empty range of a log of {size} leaves")] + Range { + /// First leaf of the range. + start: u64, + /// One past the last leaf. + end: u64, + /// The log's size at the head. + size: u64, + }, + /// The bundle does not hold one member per leaf of the range. + #[error("the bundle holds {found} members for a range of {expected} leaves")] + Count { + /// Leaves in the range. + expected: u64, + /// Members held. + found: usize, + }, + /// A member is not the leaf the range has at its position: one is + /// missing, repeated or out of order. + #[error("member {position} is leaf {found}, where the range has leaf {expected}")] + NotTheRange { + /// Its position in the bundle. + position: usize, + /// The leaf the range has there. + expected: u64, + /// The leaf it claims to be. + found: u64, + }, + /// A member's content is not the leaf at its index under the head. + #[error("leaf {index}: not the leaf under the head: {detail}")] + NotIncluded { + /// The member's leaf index. + index: u64, + /// Why. + detail: String, + }, + /// The earlier head the verifying party holds does not verify. + #[error("the earlier head does not verify: {0}")] + EarlierHead(String), + /// The verifying party holds an earlier head, and the bundle carries no + /// proof that the log grew from it. + #[error("the bundle carries no proof that the log grew from the earlier head")] + NoExtension, + /// The log did not grow from the earlier head by appending alone. + #[error("the log did not grow from the earlier head by appending: {0}")] + NotExtended(String), +} + +/// One contiguous range of a log, every record in it disclosed or withheld, +/// each proven against one signed head (see the module docs). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DisclosureBundle { + /// What the disclosure answers, such as a court's order, in the + /// producer's words. + pub answers: String, + /// The range's first leaf. + pub start: u64, + /// One past the range's last leaf. + pub end: u64, + /// The log's signed head the members are proven against. + pub head: SignedTreeHead, + /// One member per leaf of the range, in order. + pub members: Vec, + /// A proof that the log at `head` extends it as it stood at an earlier + /// size, for a party that holds the head of that size. + pub extends: Option, +} + +impl DisclosureBundle { + /// Cut a bundle for the leaves `range` of `log`, one offer per leaf in + /// order, under a head signed now by `log_key`, the log operator's key. + /// With `extends_from`, it also carries a proof that the log grew from its + /// state at that size. + /// + /// The bundle is verified before it is returned, so an offer that is not + /// the leaf at its index is refused here rather than found later. + pub fn cut( + log: &impl ProvableLog, + answers: impl Into, + range: std::ops::Range, + offers: Vec, + log_key: &SigningKey, + timestamp_unix: u64, + extends_from: Option, + ) -> Result { + let (start, end) = (range.start, range.end); + let head = log.head(timestamp_unix); + let size = head.size; + if start >= end || end > size { + return Err(DisclosureError::Range { start, end, size }); + } + if offers.len() as u64 != end - start { + return Err(DisclosureError::Count { expected: end - start, found: offers.len() }); + } + let members = (start..end) + .zip(offers) + .map(|(index, o)| { + let proof = log + .inclusion_proof(index) + .map_err(|e| DisclosureError::NotIncluded { index, detail: e.to_string() })?; + Ok(Member { index, kind: o.kind, label: o.label, content: o.content, proof }) + }) + .collect::, DisclosureError>>()?; + let extends = extends_from + .map(|m| log.consistency_proof(m).map_err(|e| DisclosureError::NotExtended(e.to_string()))) + .transpose()?; + let head = head.sign(log_key).map_err(|e| DisclosureError::Head(e.to_string()))?; + let bundle = Self { answers: answers.into(), start, end, head, members, extends }; + bundle.verify(&log_key.verifying_key(), None)?; + Ok(bundle) + } + + /// Verify the bundle against the log's key and, where the verifying party + /// holds one, an earlier signed head of the same log (see the module + /// docs for what this establishes, and what it does not). + pub fn verify(&self, log_key: &VerifyingKey, held: Option<&SignedTreeHead>) -> Result<(), DisclosureError> { + let root = self.head.verify(log_key).map_err(|e| DisclosureError::Head(e.to_string()))?; + let size = self.head.head.size; + if self.start >= self.end || self.end > size { + return Err(DisclosureError::Range { start: self.start, end: self.end, size }); + } + if self.members.len() as u64 != self.end - self.start { + return Err(DisclosureError::Count { expected: self.end - self.start, found: self.members.len() }); + } + for (position, (m, expected)) in self.members.iter().zip(self.start..self.end).enumerate() { + if m.index != expected { + return Err(DisclosureError::NotTheRange { position, expected, found: m.index }); + } + if m.proof.leaf_index != m.index || m.proof.tree_size != size { + return Err(DisclosureError::NotIncluded { + index: m.index, + detail: format!("its proof is for leaf {} of {}", m.proof.leaf_index, m.proof.tree_size), + }); + } + m.proof + .verify(&m.content_hash(), &root) + .map_err(|e| DisclosureError::NotIncluded { index: m.index, detail: e.to_string() })?; + } + if let Some(earlier) = held { + let old_root = earlier.verify(log_key).map_err(|e| DisclosureError::EarlierHead(e.to_string()))?; + let proof = self.extends.as_ref().ok_or(DisclosureError::NoExtension)?; + if proof.old_size != earlier.head.size || proof.new_size != size { + return Err(DisclosureError::NotExtended(format!( + "the proof runs from {} to {}, the heads from {} to {}", + proof.old_size, proof.new_size, earlier.head.size, size + ))); + } + proof.verify(&old_root, &root).map_err(|e| DisclosureError::NotExtended(e.to_string()))?; + } + Ok(()) + } + + /// The members withheld, with their grounds. + pub fn withheld(&self) -> Vec<(u64, Ground)> { + self.members.iter().filter_map(|m| m.withheld_on().map(|g| (m.index, g))).collect() + } + + /// The leaf indices of the members that bear on `provision`: every member + /// for a provision that bears on the bundle as a whole. + pub fn bearing_on(&self, provision: &Provision) -> Vec { + self.members + .iter() + .filter(|m| provision.kinds.is_empty() || provision.kinds.contains(&m.kind)) + .map(|m| m.index) + .collect() + } + + /// A plain index of the bundle, one line per member, for presenting it in + /// an easily accessible and easily understandable manner (Art. 9(6)). + pub fn plain_index(&self) -> String { + let mut out = format!( + "{}\nLeaves {} to {} of a log of {}, every one listed.\n", + self.answers, + self.start, + self.end - 1, + self.head.head.size + ); + for m in &self.members { + let state = m.withheld_on().map_or_else(|| "disclosed".to_string(), |g| format!("withheld: {}", g.describe())); + let _ = writeln!(out, "{:>6} {} — {} — {}", m.index, m.kind.describe(), m.label, state); + } + out + } +} + +/// A provision of Directive (EU) 2024/2853 that records in a bundle bear on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Provision { + /// Where it is, such as `Art. 10(2)(a)`. + pub article: &'static str, + /// What it provides, paraphrased. + pub provides: &'static str, + /// What a bundle can carry that bears on it. + pub bears: &'static str, + /// The kinds of record that carry it. Empty: the bundle as a whole. + pub kinds: &'static [EvidenceKind], +} + +const CROSSWALK: &[Provision] = &[ + Provision { + article: "Art. 10(2)(a)", + provides: "Defectiveness is presumed where the defendant fails to disclose relevant evidence under Art. 9(1).", + bears: "The bundle as a whole. Every record in its scope is disclosed or listed as withheld, with its ground, \ + and a withheld record's content hash fixes what a later order to disclose it must produce. Nothing in \ + the scope can be left out unseen.", + kinds: &[], + }, + Provision { + article: "Art. 10(2)(b), with Art. 7(2)(f)", + provides: "Defectiveness is presumed where the claimant shows the product does not comply with mandatory product \ + safety requirements, including safety-relevant cybersecurity requirements, that protect against the \ + risk of the damage.", + bears: "Filings under Regulation (EU) 2024/2847, Art. 14, and conformance or validation results: what was \ + reported and when, and what was tested.", + kinds: &[EvidenceKind::VulnerabilityOrIncidentFiling, EvidenceKind::ConformanceResult], + }, + Provision { + article: "Art. 10(2)(c)", + provides: "Defectiveness is presumed where the claimant shows the damage was caused by an obvious malfunction \ + during reasonably foreseeable use or under ordinary circumstances.", + bears: "Work receipts, output disclosures and the authority work ran under: what was asked, on which model, \ + with what permission, at what cost, and what came back, checked against the receipt's commitment to it.", + kinds: &[EvidenceKind::WorkReceipt, EvidenceKind::OutputDisclosure, EvidenceKind::Authority], + }, + Provision { + article: "Art. 10(3)", + provides: "The causal link is presumed where the product is defective and the damage is of a kind typically \ + consistent with the defect.", + bears: "Receipts that bind a request to its output and to the effect it had, so the record shows which output \ + led to which effect.", + kinds: &[EvidenceKind::WorkReceipt, EvidenceKind::OutputDisclosure], + }, + Provision { + article: "Art. 10(4)", + provides: "Defectiveness, the causal link or both are presumed where, despite disclosure, the claimant faces \ + excessive difficulties, in particular through technical or scientific complexity, and shows they \ + are likely.", + bears: "Replay verdicts, and the records a party needs to re-execute a deterministic unit of work itself, so an \ + account of what the product did need not rest on either party's word.", + kinds: &[EvidenceKind::ReplayVerdict, EvidenceKind::WorkReceipt, EvidenceKind::OutputDisclosure], + }, + Provision { + article: "Art. 11(2)(b) to (d), with Art. 4(18) and Art. 7(2)(c), (e)", + provides: "No exemption under Art. 11(1)(c), that the defect did not exist when the product was placed on the \ + market, where the defect is due to software, including updates or upgrades, to a lack of updates \ + needed to maintain safety, or to a substantial modification, within the manufacturer's control. A \ + modification is substantial under Art. 4(18)(b) where, among other things, it was not foreseen in \ + the manufacturer's initial risk assessment.", + bears: "Model change records: which weights served when, from which change, and whether a plan recorded before \ + the change foresaw it.", + kinds: &[EvidenceKind::ModelChange], + }, +]; + +/// The provisions records in a bundle bear on (see the module docs). +pub fn crosswalk() -> &'static [Provision] { + CROSSWALK +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cra::{Art14Subject, Filing, FilingStage, ProductStatement, ProductStatus}; + use chrono::TimeZone as _; + + fn operator() -> SigningKey { + SigningKey::from_bytes(&[11u8; 32]) + } + + fn record(i: u64) -> Vec { + format!("record {i}").into_bytes() + } + + /// A log of `n` records, each leaf the BLAKE3 of the record's bytes. + fn log_of(n: u64) -> MerkleTree { + let mut t = MerkleTree::new(); + for i in 0..n { + t.append(blake3::hash(&record(i)).as_bytes().to_vec()); + } + t + } + + fn disclosed(i: u64) -> Offer { + Offer { kind: EvidenceKind::WorkReceipt, label: format!("serve {i}"), content: Content::Disclosed(record(i)) } + } + + fn withheld(i: u64, ground: Ground) -> Offer { + Offer { + kind: EvidenceKind::ModelChange, + label: format!("weights change {i}"), + content: Content::Withheld { content_hash: *blake3::hash(&record(i)).as_bytes(), ground }, + } + } + + /// Leaves 3..7 of a 10-leaf log: two disclosed, one withheld as a trade + /// secret, one disclosed. + fn bundle() -> DisclosureBundle { + let offers = vec![disclosed(3), disclosed(4), withheld(5, Ground::TradeSecret), disclosed(6)]; + DisclosureBundle::cut(&log_of(10), "order 2027/123", 3..7, offers, &operator(), 1_800_000_000, None).unwrap() + } + + /// A bundle cut from a log verifies from the log's key alone, lists what + /// it withholds, and a withheld record produced later opens its member. + #[test] + fn a_bundle_verifies_and_pins_what_it_withholds() { + let b = bundle(); + b.verify(&operator().verifying_key(), None).unwrap(); + assert_eq!(b.withheld(), vec![(5, Ground::TradeSecret)]); + let withheld = &b.members[2]; + assert!(withheld.opened_by(&record(5)), "the record ordered disclosed later is the one it withheld"); + assert!(!withheld.opened_by(b"another record")); + let index = b.plain_index(); + assert!(index.contains("Leaves 3 to 6 of a log of 10, every one listed.")); + assert!(index.contains("withheld: trade secret (Art. 9(4), 9(5))")); + assert_eq!(index.lines().count(), 2 + 4, "a line per member"); + } + + /// A record in scope cannot be left out unseen: dropping, repeating or + /// reordering a member, or narrowing the range under it, does not verify. + #[test] + fn a_bundle_cannot_leave_a_record_out_unseen() { + let vk = operator().verifying_key(); + let b = bundle(); + + let mut dropped = b.clone(); + dropped.members.remove(1); + assert!(matches!(dropped.verify(&vk, None), Err(DisclosureError::Count { expected: 4, found: 3 }))); + + let mut dropped_and_narrowed = dropped.clone(); + dropped_and_narrowed.end = 6; + assert!(matches!( + dropped_and_narrowed.verify(&vk, None), + Err(DisclosureError::NotTheRange { position: 1, expected: 4, found: 5 }) + )); + + let mut repeated = b.clone(); + repeated.members[1] = repeated.members[0].clone(); + assert!(matches!(repeated.verify(&vk, None), Err(DisclosureError::NotTheRange { position: 1, .. }))); + + let mut reordered = b.clone(); + reordered.members.swap(1, 2); + assert!(matches!(reordered.verify(&vk, None), Err(DisclosureError::NotTheRange { position: 1, .. }))); + + let mut past_the_log = b.clone(); + past_the_log.end = 11; + assert!(matches!(past_the_log.verify(&vk, None), Err(DisclosureError::Range { .. }))); + let mut empty = b.clone(); + (empty.start, empty.end, empty.members) = (3, 3, vec![]); + assert!(matches!(empty.verify(&vk, None), Err(DisclosureError::Range { .. }))); + } + + /// A member is the leaf at its index: altered bytes, a substituted hash, + /// or a proof for another leaf or another tree do not verify, and neither + /// does a head another key signed. + #[test] + fn a_member_is_the_leaf_at_its_index() { + let vk = operator().verifying_key(); + let b = bundle(); + + let mut altered = b.clone(); + altered.members[0].content = Content::Disclosed(b"record 3, amended".to_vec()); + assert!(matches!(altered.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 3, .. }))); + + let mut substituted = b.clone(); + substituted.members[2].content = + Content::Withheld { content_hash: *blake3::hash(b"something else").as_bytes(), ground: Ground::TradeSecret }; + assert!(matches!(substituted.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 5, .. }))); + + // Leaf 3, content and proof, presented as leaf 4: its proof holds, for + // another leaf, and leaf 4 would be missing unseen. + let mut relabeled = b.clone(); + relabeled.members[1].content = b.members[0].content.clone(); + relabeled.members[1].proof = b.members[0].proof.clone(); + assert!(matches!(relabeled.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 4, .. }))); + + let mut borrowed = b.clone(); + borrowed.members[1].proof = b.members[0].proof.clone(); + assert!(matches!(borrowed.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 4, .. }))); + + let mut other_tree = b.clone(); + other_tree.members[1].proof = log_of(12).inclusion_proof(4).unwrap(); + assert!(matches!(other_tree.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 4, .. }))); + + let stranger = SigningKey::from_bytes(&[12u8; 32]).verifying_key(); + assert!(matches!(b.verify(&stranger, None), Err(DisclosureError::Head(_)))); + } + + /// A proof is read against the head's own tree size. Under a 3-leaf root, + /// leaf 2 has a valid proof as "leaf 1 of a 2-leaf tree", because the + /// 3-leaf root is H(H(leaf 0, leaf 1), leaf 2). Taken at its word, that + /// proof would let leaf 2's record stand in for leaf 1's, and leaf 1 would + /// go missing unseen. + #[test] + fn a_proof_is_read_at_the_heads_size() { + let three = log_of(3); + let b = DisclosureBundle::cut(&three, "order", 0..3, vec![disclosed(0), disclosed(1), disclosed(2)], &operator(), 0, None) + .unwrap(); + let lh = |i: u64| jouleclaw_transparency_log::leaf_hash(blake3::hash(&record(i)).as_bytes()); + let crafted = InclusionProof { + leaf_index: 1, + tree_size: 2, + audit_path: vec![jouleclaw_transparency_log::node_hash(&lh(0), &lh(1))], + }; + assert!( + crafted.verify(blake3::hash(&record(2)).as_bytes(), &three.root()).is_ok(), + "the crafted proof is valid against the root" + ); + let mut stand_in = b.clone(); + stand_in.members[1].content = Content::Disclosed(record(2)); + stand_in.members[1].proof = crafted; + let refused = stand_in.verify(&operator().verifying_key(), None); + assert!(matches!(refused, Err(DisclosureError::NotIncluded { index: 1, .. }))); + } + + /// A party holding an earlier head of the log checks that the log grew + /// from it by appending alone, and a bundle that cannot show it is refused. + #[test] + fn a_bundle_shows_the_log_grew_from_a_head_the_party_holds() { + let vk = operator().verifying_key(); + let earlier = log_of(5).head(1_700_000_000).sign(&operator()).unwrap(); + let offers = vec![disclosed(6), disclosed(7)]; + let b = DisclosureBundle::cut(&log_of(10), "order", 6..8, offers.clone(), &operator(), 1_800_000_000, Some(5)) + .unwrap(); + b.verify(&vk, Some(&earlier)).unwrap(); + + let without = DisclosureBundle::cut(&log_of(10), "order", 6..8, offers.clone(), &operator(), 1_800_000_000, None) + .unwrap(); + assert!(matches!(without.verify(&vk, Some(&earlier)), Err(DisclosureError::NoExtension))); + + // The party held a head of a log that was later rewritten. + let mut rewritten = MerkleTree::new(); + for i in 0..5 { + rewritten.append(blake3::hash(format!("rewritten {i}").as_bytes()).as_bytes().to_vec()); + } + let rewritten_head = rewritten.head(1_700_000_000).sign(&operator()).unwrap(); + assert!(matches!(b.verify(&vk, Some(&rewritten_head)), Err(DisclosureError::NotExtended(_)))); + + let other_size = log_of(4).head(1_700_000_000).sign(&operator()).unwrap(); + let refused = b.verify(&vk, Some(&other_size)); + assert!(matches!(refused, Err(DisclosureError::NotExtended(d)) if d.contains("runs from 5 to 10, the heads from 4"))); + + let foreign = log_of(5).head(1_700_000_000).sign(&SigningKey::from_bytes(&[12u8; 32])).unwrap(); + assert!(matches!(b.verify(&vk, Some(&foreign)), Err(DisclosureError::EarlierHead(_)))); + } + + /// A producer cannot cut a bundle whose offers are not the log's records, + /// nor one that does not cover its range. + #[test] + fn a_bundle_is_refused_at_the_cut() { + let log = log_of(10); + let wrong = vec![disclosed(3), disclosed(3)]; + assert!(matches!( + DisclosureBundle::cut(&log, "order", 3..5, wrong, &operator(), 0, None), + Err(DisclosureError::NotIncluded { index: 4, .. }) + )); + assert!(matches!( + DisclosureBundle::cut(&log, "order", 3..5, vec![disclosed(3)], &operator(), 0, None), + Err(DisclosureError::Count { expected: 2, found: 1 }) + )); + assert!(matches!( + DisclosureBundle::cut(&log, "order", 3..4, vec![disclosed(3), disclosed(4)], &operator(), 0, None), + Err(DisclosureError::Count { expected: 1, found: 2 }) + )); + assert!(matches!( + DisclosureBundle::cut(&log, "order", 9..11, vec![disclosed(9), disclosed(10)], &operator(), 0, None), + Err(DisclosureError::Range { .. }) + )); + } + + /// A CRA Art. 14 case's filing chain is a log a bundle can be cut from. + #[test] + fn a_filing_chain_can_be_disclosed() { + let at = |h: u32| chrono::Utc.with_ymd_and_hms(2026, 10, 1, h, 0, 0).unwrap(); + let mut chain = FilingChain::new("case-1", Art14Subject::SevereIncident); + let warning = Filing { + case_id: "case-1".into(), + subject: Art14Subject::SevereIncident, + stage: FilingStage::EarlyWarning, + became_aware_at: at(1), + filed_at: at(2), + corrective_measure_available_at: None, + statements: vec![ProductStatement { product: "product-1".into(), status: ProductStatus::UnderInvestigation }], + also_provides: vec![], + summary: "early warning".into(), + supersedes: None, + }; + chain.file(warning.clone()).unwrap(); + let offer = Offer { + kind: EvidenceKind::VulnerabilityOrIncidentFiling, + label: "early warning".into(), + content: Content::Disclosed(warning.canonical_bytes()), + }; + let b = DisclosureBundle::cut(&chain, "order", 0..1, vec![offer], &operator(), 0, None).unwrap(); + b.verify(&operator().verifying_key(), None).unwrap(); + let cra_row = crosswalk().iter().find(|p| p.article.starts_with("Art. 10(2)(b)")).unwrap(); + assert_eq!(b.bearing_on(cra_row), vec![0]); + } + + /// The crosswalk names each provision once. A provision on the bundle as + /// a whole reaches every member; the others reach the kinds they list. + #[test] + fn the_crosswalk_maps_records_to_provisions() { + let rows = crosswalk(); + let articles: std::collections::BTreeSet<_> = rows.iter().map(|p| p.article).collect(); + assert_eq!(articles.len(), rows.len()); + + let b = bundle(); + let whole = rows.iter().find(|p| p.article == "Art. 10(2)(a)").unwrap(); + assert_eq!(b.bearing_on(whole), vec![3, 4, 5, 6]); + let malfunction = rows.iter().find(|p| p.article == "Art. 10(2)(c)").unwrap(); + assert_eq!(b.bearing_on(malfunction), vec![3, 4, 6]); + let change = rows.iter().find(|p| p.article.starts_with("Art. 11(2)")).unwrap(); + assert_eq!(b.bearing_on(change), vec![5], "the withheld change still bears on it, by its hash"); + } +} -- 2.54.0 (Apple Git-157)