jouleclaw-compliance: a scoped PLD disclosure bundle, and a crosswalk to the provisions #384

Merged
dcharlot merged 1 commit from feat/compliance-product-liability into main 2026-10-21 10:18:50 -04:00
3 changed files with 764 additions and 2 deletions

View file

@ -18,6 +18,4 @@ serde_json = { workspace = true }
thiserror.workspace = true
chrono = { workspace = true }
blake3 = { workspace = true }
[dev-dependencies]
ed25519-dalek = { workspace = true }

View file

@ -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,

View file

@ -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<u8>),
/// 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<Ground> {
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<InclusionProof, LogError>;
/// 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<ConsistencyProof, LogError>;
}
impl ProvableLog for MerkleTree {
fn head(&self, timestamp_unix: u64) -> TreeHead {
TreeHead::of(self, timestamp_unix)
}
fn inclusion_proof(&self, index: u64) -> Result<InclusionProof, LogError> {
MerkleTree::inclusion_proof(self, index)
}
fn consistency_proof(&self, old_size: u64) -> Result<ConsistencyProof, LogError> {
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<InclusionProof, LogError> {
FilingChain::inclusion_proof(self, index)
}
fn consistency_proof(&self, old_size: u64) -> Result<ConsistencyProof, LogError> {
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<Member>,
/// 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<ConsistencyProof>,
}
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<String>,
range: std::ops::Range<u64>,
offers: Vec<Offer>,
log_key: &SigningKey,
timestamp_unix: u64,
extends_from: Option<u64>,
) -> Result<Self, DisclosureError> {
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::<Result<Vec<_>, 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<u64> {
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<u8> {
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");
}
}