jouleclaw-compliance: a scoped PLD disclosure bundle, and a crosswalk to the provisions #384
3 changed files with 764 additions and 2 deletions
|
|
@ -18,6 +18,4 @@ serde_json = { workspace = true }
|
|||
thiserror.workspace = true
|
||||
chrono = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
ed25519-dalek = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue