From 66cd9bd8068f4af21253436abba32fbbc4c2be1a Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 6 Oct 2026 15:17:33 +0200 Subject: [PATCH 1/2] feat(domain): add the archived version port --- CHANGELOG.md | 49 +++++ CLAUDE.md | 1 + README.md | 3 +- crates/dpp-domain/Cargo.toml | 15 +- crates/dpp-domain/src/lib.rs | 1 + crates/dpp-domain/src/ports/archive/mod.rs | 94 +++++++++ crates/dpp-domain/src/ports/archive/port.rs | 69 +++++++ .../dpp-domain/src/ports/archive/receipt.rs | 24 +++ crates/dpp-domain/src/ports/archive/stub.rs | 120 +++++++++++ crates/dpp-domain/src/ports/archive/tests.rs | 188 ++++++++++++++++++ .../dpp-domain/src/ports/archive/version.rs | 28 +++ crates/dpp-domain/src/ports/backup/mod.rs | 16 +- crates/dpp-domain/src/ports/backup/port.rs | 4 +- crates/dpp-domain/src/ports/backup/receipt.rs | 5 +- crates/dpp-domain/src/ports/backup/stub.rs | 17 +- crates/dpp-domain/src/ports/mod.rs | 1 + docs/architecture/ARCHITECTURE.md | 1 + docs/architecture/OVERVIEW.md | 5 +- docs/architecture/PORTS.md | 6 +- 19 files changed, 619 insertions(+), 28 deletions(-) create mode 100644 crates/dpp-domain/src/ports/archive/mod.rs create mode 100644 crates/dpp-domain/src/ports/archive/port.rs create mode 100644 crates/dpp-domain/src/ports/archive/receipt.rs create mode 100644 crates/dpp-domain/src/ports/archive/stub.rs create mode 100644 crates/dpp-domain/src/ports/archive/tests.rs create mode 100644 crates/dpp-domain/src/ports/archive/version.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index add13889..5dbb8898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,47 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for ### Added +- **A port for the archive of a passport's historical versions.** `ports::archive` + adds `ArchivedVersionPort`, the functionality EN 18221:2026 clause 4.2 calls + archiving: the version a change replaces is kept, append-only, for the + passport's lifetime, so the passport as it stood at any earlier moment can be + retrieved. Core could not express it. `BackupCopyPort` holds one copy and has no + method that takes or returns a series, so a back-up provider had nothing in core + to implement for the back-up half of clause 4.2, and a deployment that kept + versions had to use a trait of its own. + - `archive` takes the passport id, the document and the instant it was + superseded, and returns an `ArchiveReceipt` carrying the version's hash. + `versions` lists them oldest first. `version_at` answers which was current at + an instant, half-open on `superseded_at`. + - A retry is safe: archiving a version already held returns its original + receipt. A version that would precede the latest, or share an instant with a + different one, is refused. + - The document is a `serde_json::Value`, not a typed `Passport`. An archive is + evidence, and reading a document through a struct drops what the struct does + not know, which changes its hash and the signature over it. + - The port returns whole documents and applies no disclosure policy. The caller + does, since clause 4.2 gives an archived attribute the same access restriction + as the current one. + - It is separate from `BackupCopyPort` by shape, not by actor: a back-up + provider implements both. The Regulation's own text asks the back-up only for + the most up-to-date version. Holding history there is what the presumption of + conformity under the standard costs, and the docs say so rather than calling it + a legal requirement. + - **The content hash is now defined, for both ports:** SHA-256 of the RFC 8785 + canonical form, as lower-case hexadecimal. `BackupReceipt::content_hash` had + no definition, and the in-memory back-up hashed plain `serde_json` output. It + now follows the same definition, so one document carries one hash in either + port. + - `InMemoryArchive` ships with the `test-utils` feature, which now enables + `dpp-rules/bundle` for the canonical hash. `sha2` and `hex` are no longer + optional dependencies of `dpp-domain`, so the `sha2` and `hex` features that + they implied are gone. + + Not covered: a bound on how far behind a back-up may lag (clause 4.5), though the + receipt's `archived_at` against `superseded_at` is the means to measure it; and + any no-op implementation, deliberately, since one that kept nothing would make a + deployment look as if it archived. + - **A standards register, and a tripwire that holds the code to it.** `docs/architecture/STANDARDS.md` records the IETF, W3C, GS1, IDTA and ETSI specifications the repository cites. ISO/IEC and IEC standards are not yet @@ -244,6 +285,14 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for ### Documentation +- **The back-up copy's availability period was cited to the wrong place.** + `BackupCopyPort`'s docs and the port inventory gave the period as ESPR Annex + III(i), which lists unique facility identifiers. The period is Art. 9(2)(i), + which has the passport remain available for at least the expected lifetime of + the product, to be set per product group by each delegated act. Annex III(l), + the provider's reference, was cited correctly. The README also stops quoting a + port count: `PORTS.md` is the one place that does. + - **Technical specifications have one home.** README's coverage table and the conformity statement each gave their own status for GS1 Digital Link, the IDTA AAS metamodel and VC Data Model 2.0, and the two had disagreed. Those diff --git a/CLAUDE.md b/CLAUDE.md index 0d831f36..8feb66a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,6 +218,7 @@ Port traits define the core/platform boundary: - `IdentityPort` (async, sign/verify) - `PluginHost` (non-async, Wasm dispatch) - `BackupCopyPort` (async, the ESPR Art. 10(4) third-party back-up copy) +- `ArchivedVersionPort` (async, the EN 18221 clause 4.2 archive of a passport's historical versions) - `RegistrySyncPort` (async, EU Central Registry registration/status sync) - `SealPort` (async, eIDAS qualified electronic seal — ESPR Art. 13 / eIDAS 910/2014) diff --git a/README.md b/README.md index 1ee13e2e..11bd7b40 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ has mapped is exactly where an invented identifier otherwise passes unexamined. ## Port Traits -The eight port traits define the core/platform boundary. Any downstream project implements these against its own infrastructure: +The port traits define the core/platform boundary. Any downstream project implements these against its own infrastructure: | Trait | Kind | Purpose | |---|---|---| @@ -220,6 +220,7 @@ The eight port traits define the core/platform boundary. Any downstream project | `IdentityPort` | async | Sign and verify passport JWS | | `PluginHost` | sync | Wasm plugin dispatch | | `BackupCopyPort` | async | The ESPR Art. 10(4) third-party back-up copy | +| `ArchivedVersionPort` | async | The EN 18221 clause 4.2 archive of a passport's historical versions | | `RegistrySyncPort` | async | EU Central Registry registration and status sync | | `SealPort` | async | eIDAS qualified electronic seal (ESPR Art. 13 / eIDAS 910/2014) | diff --git a/crates/dpp-domain/Cargo.toml b/crates/dpp-domain/Cargo.toml index 8f7da043..3095e354 100644 --- a/crates/dpp-domain/Cargo.toml +++ b/crates/dpp-domain/Cargo.toml @@ -36,17 +36,14 @@ semver = "1" jsonschema = { workspace = true } [features] -test-utils = ["sha2", "hex"] - -[dependencies.sha2] -workspace = true -optional = true - -[dependencies.hex] -workspace = true -optional = true +# The in-memory back-up and archive hash a document the way both ports define it, +# over its RFC 8785 canonical form, which `dpp-rules` supplies behind `bundle`. +test-utils = ["dpp-rules/bundle"] [dev-dependencies] +# `bundle` for the crate's own tests, which compile the in-memory stubs without +# the `test-utils` feature. +dpp-rules = { workspace = true, features = ["bundle"] } serde_json = { workspace = true } tokio = { version = "1", features = ["macros", "rt"] } sha2 = { workspace = true } diff --git a/crates/dpp-domain/src/lib.rs b/crates/dpp-domain/src/lib.rs index b26b5bc5..f246d126 100644 --- a/crates/dpp-domain/src/lib.rs +++ b/crates/dpp-domain/src/lib.rs @@ -147,6 +147,7 @@ pub use compliance::{ ComplianceError, ComplianceErrorKind, ComplianceFinding, ComplianceResult, ComplianceStatus, gate_determination, }; +pub use ports::archive::{ArchiveReceipt, ArchivedVersion, ArchivedVersionPort}; pub use ports::backup::{ BackupCopyPort, BackupReceipt, BackupStatus, BackupVerification, GhostBackup, }; diff --git a/crates/dpp-domain/src/ports/archive/mod.rs b/crates/dpp-domain/src/ports/archive/mod.rs new file mode 100644 index 00000000..0e57966a --- /dev/null +++ b/crates/dpp-domain/src/ports/archive/mod.rs @@ -0,0 +1,94 @@ +//! Port trait for the **archive** of a live passport's historical versions, the +//! functionality EN 18221:2026 clause 4.2 calls archiving. +//! +//! A passport changes. Each time it does, the version it had just before the +//! change is archived, so that the passport as it stood at any earlier moment can +//! be retrieved by those entitled to read it. Archiving starts at the first change +//! and keeps every version from then on, for the passport's lifetime. **Creating a +//! passport archives nothing**: there is no earlier version to keep. +//! +//! # Not the back-up copy +//! +//! [`BackupCopyPort`](crate::ports::backup::BackupCopyPort) holds **one copy of +//! one record**, so that the passport survives its operator (ESPR Art. 10(4)). +//! This port holds **a series of versions of one record**. They are different +//! shapes answering different obligations, and neither implies the other. +//! +//! **The line is drawn by shape, never by actor.** Clause 4.2 expects the archived +//! versions to be held by the back-up provider as well as by the main store, and +//! clause 4.3 has the back-up hold the latest version and the historical ones. So +//! a back-up provider implements this port alongside `BackupCopyPort`, and the +//! main store implements it too. Nothing here exempts a provider from archiving, +//! and nothing on `BackupCopyPort` exempts the main store from keeping a copy. +//! +//! # What the law requires, and what the standard adds +//! +//! ESPR Art. 10(4) asks for a back-up copy, and Arts. 27(1)(c) and 29 make it a +//! copy of the most up-to-date version. The Regulation's own text therefore does +//! not ask the back-up for history. The standard does: its Annex ZA maps +//! Art. 10(4) to clauses 4.3 and 4.5, so a back-up that holds history is what the +//! presumption of conformity under Art. 41(2) costs. Say it that way and no +//! other: it is not a requirement of the Regulation. +//! +//! # The contract +//! +//! - **Archiving starts at the first change.** A caller archives the version a +//! change replaces, at the moment it replaces it, and archives nothing when it +//! creates a passport. +//! - **Versions are append-only, and kept for the passport's lifetime.** The port +//! has no method that changes or removes one, on purpose. +//! - **The archived document is the passport as it stood**, whole and as written. +//! It is a [`serde_json::Value`] and not a typed `Passport`, because an archive +//! is evidence: reading a document through a struct drops what the struct does +//! not know, which changes its bytes, its hash and the signature over it. A +//! version written under an older shape is read back through the lens machinery +//! by the caller, never by the port. +//! - **The port returns whole documents and applies no disclosure policy.** The +//! caller does. Clause 4.2 gives an archived attribute the same access +//! restriction as the corresponding current one, so the live passport's policy +//! in force now applies, and serving an archived version without it leaks +//! exactly what serving the live document without it would. +//! - **Replication.** The main store writes synchronously. A back-up provider may +//! lag behind it, and clause 4.5 asks that it be kept close. This contract +//! declares no bound on how far behind is acceptable. What it does give is the +//! means to measure it: [`ArchiveReceipt::archived_at`] against the version's +//! `superseded_at`. +//! +//! # The content hash +//! +//! `content_hash` is the lower-case hexadecimal SHA-256 of the RFC 8785 (JCS) +//! canonical form of the document. `BackupReceipt::content_hash` is defined the +//! same way, so a version held here and the back-up copy of that same version +//! carry one hash, and the two can be matched. +//! +//! A registry proof of registration carries a hash of the passport version it +//! covers (CIR (EU) 2026/1778 Art. 9(2)(e)). The Regulation names no algorithm, +//! so which hash a registry uses is not settled by anything held here. That does +//! not make this one wrong: because the port returns whole documents, a caller can +//! derive whichever hash it is asked for from `doc`. The receipt's hash is for +//! integrity, and for matching versions across the two ports. +//! +//! # Not covered +//! +//! - **Transport.** How a provider is reached is the business of standards this +//! workspace does not hold. +//! - **Access through the back-up once the operator has left the market** +//! (clause 4.3). That is a separate design question. +//! - **Integrity protection beyond the hash** (EN 18246). The version hash is the +//! part that can be done now. +//! +//! There is deliberately **no no-op implementation**, unlike the other ports. A +//! ghost that accepted versions and kept none would make a deployment look as if +//! it archived. + +mod port; +mod receipt; +#[cfg(any(test, feature = "test-utils"))] +pub mod stub; +#[cfg(test)] +mod tests; +mod version; + +pub use port::ArchivedVersionPort; +pub use receipt::ArchiveReceipt; +pub use version::ArchivedVersion; diff --git a/crates/dpp-domain/src/ports/archive/port.rs b/crates/dpp-domain/src/ports/archive/port.rs new file mode 100644 index 00000000..8130acb0 --- /dev/null +++ b/crates/dpp-domain/src/ports/archive/port.rs @@ -0,0 +1,69 @@ +//! [`ArchivedVersionPort`] — the contract a holder of archived versions +//! implements. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use super::receipt::ArchiveReceipt; +use super::version::ArchivedVersion; +use crate::error::DppError; +use crate::passport::PassportId; + +/// Port trait for archiving the historical versions of a live passport. +/// +/// Implemented by the main store, and by a back-up provider alongside +/// [`BackupCopyPort`](crate::ports::backup::BackupCopyPort). The module docs state +/// the contract; the method docs state what each call owes. +#[async_trait] +pub trait ArchivedVersionPort: Send + Sync { + /// Archive the version of a passport that a change has just replaced. + /// + /// `doc` is the passport as it stood immediately before the change, whole and + /// as written. `superseded_at` is the instant the change took effect. The port + /// does not read `doc`'s shape: `passport_id` says whose version it is. + /// + /// Versions are kept in order of `superseded_at`, and each must be later than + /// the one before it, since a version is current from the moment its + /// predecessor was replaced until its own `superseded_at`, and two changes + /// cannot share an instant. A call that is not later than the latest archived + /// version is refused with [`DppError::Validation`], **unless** it repeats a + /// version already held. + /// + /// **A retry is safe.** Archiving a version whose `superseded_at` and content + /// are the same as one already held returns that version's original receipt + /// and keeps nothing new, so a caller that lost the answer can ask again, + /// including after later versions have been archived. The same `superseded_at` + /// with different content is refused with [`DppError::Validation`], because + /// two versions cannot both be the one that ended then. + async fn archive( + &self, + passport_id: PassportId, + doc: &serde_json::Value, + superseded_at: DateTime, + ) -> Result; + + /// Every archived version of a passport, oldest first. + /// + /// Returns an empty list for a passport with no archived version. That is not + /// an error and it does not say the passport is unknown: this port knows only + /// what has been archived, and a passport that has never changed has nothing + /// archived. Whole documents come back, with no disclosure policy applied. + async fn versions(&self, passport_id: PassportId) -> Result, DppError>; + + /// The archived version that was current at `at`, if one is archived. + /// + /// That is the archived version with the earliest `superseded_at` **after** + /// `at`. The interval is half-open, so at a version's own `superseded_at` it + /// is no longer current and the next one is. + /// + /// `None` means no archived version was current then, which makes the live + /// record the answer. **The port cannot say which it is, or whether the + /// passport existed at `at` at all.** It does not know when a passport was + /// created, so for an `at` before that it still returns the first archived + /// version. A caller checks `at` against the live record. + async fn version_at( + &self, + passport_id: PassportId, + at: DateTime, + ) -> Result, DppError>; +} diff --git a/crates/dpp-domain/src/ports/archive/receipt.rs b/crates/dpp-domain/src/ports/archive/receipt.rs new file mode 100644 index 00000000..57eddb61 --- /dev/null +++ b/crates/dpp-domain/src/ports/archive/receipt.rs @@ -0,0 +1,24 @@ +//! [`ArchiveReceipt`] — what a holder returns once it has archived a version. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::passport::PassportId; + +/// Confirmation that a version is archived. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveReceipt { + /// The passport the archived version belongs to. + pub passport_id: PassportId, + /// The instant the archived version was replaced. With the passport, this + /// identifies the version. + pub superseded_at: DateTime, + /// SHA-256 of the RFC 8785 canonical form of the archived document, as + /// lower-case hexadecimal. + pub content_hash: String, + /// When the holder accepted the version. Against + /// [`superseded_at`](Self::superseded_at) this is how far behind a holder that + /// lags was when it took this one. + pub archived_at: DateTime, +} diff --git a/crates/dpp-domain/src/ports/archive/stub.rs b/crates/dpp-domain/src/ports/archive/stub.rs new file mode 100644 index 00000000..8557183e --- /dev/null +++ b/crates/dpp-domain/src/ports/archive/stub.rs @@ -0,0 +1,120 @@ +//! [`InMemoryArchive`] — a `HashMap`-backed archive for tests and local runs. + +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use super::{ArchiveReceipt, ArchivedVersion, ArchivedVersionPort}; +use crate::error::DppError; +use crate::field_error::ValidationErrors; +use crate::passport::PassportId; + +/// A version with the receipt it was given, kept so that a retry can be answered +/// with the original rather than a fresh one. +struct Held { + version: ArchivedVersion, + receipt: ArchiveReceipt, +} + +/// Holds every archived version in memory, oldest first, and honours the whole +/// contract of [`ArchivedVersionPort`]: ordering, retry and refusal included. +pub struct InMemoryArchive { + held: Mutex>>, +} + +impl InMemoryArchive { + pub fn new() -> Self { + Self { + held: Mutex::new(HashMap::new()), + } + } +} + +impl Default for InMemoryArchive { + fn default() -> Self { + Self::new() + } +} + +fn refused(reason: String) -> DppError { + DppError::Validation(ValidationErrors::message(reason)) +} + +#[async_trait] +impl ArchivedVersionPort for InMemoryArchive { + async fn archive( + &self, + passport_id: PassportId, + doc: &serde_json::Value, + superseded_at: DateTime, + ) -> Result { + let content_hash = dpp_rules::canonical::content_hash(doc) + .map_err(|e| DppError::Serialisation(e.to_string()))?; + let mut held = self.held.lock().unwrap(); + let series = held.entry(passport_id).or_default(); + + if let Some(same_instant) = series + .iter() + .find(|h| h.version.superseded_at == superseded_at) + { + return if same_instant.version.content_hash == content_hash { + Ok(same_instant.receipt.clone()) + } else { + Err(refused(format!( + "a different version of {passport_id} is already archived as superseded at \ + {superseded_at}" + ))) + }; + } + if let Some(latest) = series.last() + && latest.version.superseded_at > superseded_at + { + return Err(refused(format!( + "a version of {passport_id} superseded at {superseded_at} would precede the \ + latest archived one, superseded at {}", + latest.version.superseded_at + ))); + } + + let receipt = ArchiveReceipt { + passport_id, + superseded_at, + content_hash: content_hash.clone(), + archived_at: Utc::now(), + }; + series.push(Held { + version: ArchivedVersion { + passport_id, + doc: doc.clone(), + superseded_at, + content_hash, + }, + receipt: receipt.clone(), + }); + Ok(receipt) + } + + async fn versions(&self, passport_id: PassportId) -> Result, DppError> { + let held = self.held.lock().unwrap(); + Ok(held + .get(&passport_id) + .map(|series| series.iter().map(|h| h.version.clone()).collect()) + .unwrap_or_default()) + } + + async fn version_at( + &self, + passport_id: PassportId, + at: DateTime, + ) -> Result, DppError> { + let held = self.held.lock().unwrap(); + Ok(held.get(&passport_id).and_then(|series| { + series + .iter() + .find(|h| h.version.superseded_at > at) + .map(|h| h.version.clone()) + })) + } +} diff --git a/crates/dpp-domain/src/ports/archive/tests.rs b/crates/dpp-domain/src/ports/archive/tests.rs new file mode 100644 index 00000000..843f0dfd --- /dev/null +++ b/crates/dpp-domain/src/ports/archive/tests.rs @@ -0,0 +1,188 @@ +//! Behaviour of the in-memory archive against the port contract. + +use super::stub::InMemoryArchive; +use super::*; +use crate::error::DppError; +use crate::passport::PassportId; +use crate::ports::backup::BackupCopyPort; +use crate::ports::backup::stub::InMemoryBackup; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use serde_json::json; +use sha2::{Digest, Sha256}; + +/// A fixed instant, `minutes` after an arbitrary start. +fn at(minutes: i64) -> DateTime { + Utc.with_ymd_and_hms(2027, 3, 1, 12, 0, 0).unwrap() + Duration::minutes(minutes) +} + +fn doc(label: &str) -> serde_json::Value { + json!({ "productName": label }) +} + +#[tokio::test] +async fn versions_come_back_oldest_first_with_their_documents() { + let archive = InMemoryArchive::new(); + let id = PassportId::new(); + for (label, minute) in [("first", 10), ("second", 20), ("third", 30)] { + archive.archive(id, &doc(label), at(minute)).await.unwrap(); + } + + let versions = archive.versions(id).await.unwrap(); + let held: Vec<(&str, DateTime)> = versions + .iter() + .map(|v| (v.doc["productName"].as_str().unwrap(), v.superseded_at)) + .collect(); + assert_eq!( + held, + [("first", at(10)), ("second", at(20)), ("third", at(30))] + ); + assert!(versions.iter().all(|v| v.passport_id == id)); +} + +/// A passport that never changed has nothing archived, and the port cannot tell +/// that from a passport it has never heard of: both are an empty answer, not an +/// error. One passport's versions are not another's. +#[tokio::test] +async fn a_passport_with_nothing_archived_has_no_versions() { + let archive = InMemoryArchive::new(); + let other = PassportId::new(); + archive + .archive(PassportId::new(), &doc("elsewhere"), at(10)) + .await + .unwrap(); + + assert!(archive.versions(other).await.unwrap().is_empty()); + assert!(archive.version_at(other, at(5)).await.unwrap().is_none()); +} + +/// Each version is current from the moment its predecessor was replaced until its +/// own `superseded_at`, half-open: at that instant the next one is current. +#[tokio::test] +async fn version_at_is_half_open_on_superseded_at() { + let archive = InMemoryArchive::new(); + let id = PassportId::new(); + for (label, minute) in [("first", 10), ("second", 20), ("third", 30)] { + archive.archive(id, &doc(label), at(minute)).await.unwrap(); + } + + let current = |minute: i64| { + let archive = &archive; + async move { + archive + .version_at(id, at(minute)) + .await + .unwrap() + .map(|v| v.doc["productName"].as_str().unwrap().to_owned()) + } + }; + // Before the first change the port still answers with the first version: it + // does not know when the passport was created. + assert_eq!(current(5).await.as_deref(), Some("first")); + assert_eq!(current(10).await.as_deref(), Some("second")); + assert_eq!(current(15).await.as_deref(), Some("second")); + assert_eq!(current(20).await.as_deref(), Some("third")); + assert_eq!(current(29).await.as_deref(), Some("third")); + // After the last archived change the live record is the answer. + assert_eq!(current(30).await, None); + assert_eq!(current(99).await, None); +} + +#[tokio::test] +async fn a_version_that_would_precede_the_latest_is_refused_and_nothing_is_kept() { + let archive = InMemoryArchive::new(); + let id = PassportId::new(); + archive.archive(id, &doc("later"), at(20)).await.unwrap(); + + let refused = archive.archive(id, &doc("earlier"), at(10)).await; + assert!( + matches!(refused, Err(DppError::Validation(_))), + "{refused:?}" + ); + assert_eq!(archive.versions(id).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn one_instant_cannot_end_two_different_versions() { + let archive = InMemoryArchive::new(); + let id = PassportId::new(); + archive.archive(id, &doc("held"), at(10)).await.unwrap(); + + let refused = archive.archive(id, &doc("rival"), at(10)).await; + assert!( + matches!(refused, Err(DppError::Validation(_))), + "{refused:?}" + ); + let versions = archive.versions(id).await.unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].doc, doc("held")); +} + +/// A caller that lost the answer can ask again, even after later versions have +/// been archived, and gets the original receipt back. +#[tokio::test] +async fn a_retry_returns_the_original_receipt_and_keeps_nothing_new() { + let archive = InMemoryArchive::new(); + let id = PassportId::new(); + let original = archive.archive(id, &doc("first"), at(10)).await.unwrap(); + archive.archive(id, &doc("second"), at(20)).await.unwrap(); + + let retried = archive.archive(id, &doc("first"), at(10)).await.unwrap(); + assert_eq!(retried, original); + assert_eq!(archive.versions(id).await.unwrap().len(), 2); +} + +/// The hash is SHA-256 over the RFC 8785 form. The expectation is the canonical +/// bytes written out by hand, not this crate's own canonicaliser, so the test +/// cannot agree with a wrong implementation. `serde_json` already sorts keys, so +/// the number is what tells the two apart: RFC 8785 writes `1.0` as `1`, and +/// plain `serde_json` writes it as `1.0`. +#[tokio::test] +async fn the_hash_is_the_sha256_of_the_rfc_8785_form() { + let archive = InMemoryArchive::new(); + let id = PassportId::new(); + let unordered: serde_json::Value = serde_json::from_str(r#"{"b": 1.0, "a": [2, 3]}"#).unwrap(); + let expected = hex::encode(Sha256::digest(br#"{"a":[2,3],"b":1}"#)); + + let receipt = archive.archive(id, &unordered, at(10)).await.unwrap(); + + assert_eq!(receipt.content_hash, expected); + assert_eq!( + archive.versions(id).await.unwrap()[0].content_hash, + expected + ); +} + +/// An archive is evidence, so what comes back is what went in, including what no +/// struct in this crate knows about. +#[tokio::test] +async fn the_document_is_kept_as_written() { + let archive = InMemoryArchive::new(); + let id = PassportId::new(); + let written = json!({ + "productName": "Test", + "aFieldNoStructKnows": { "nested": [1, null, "x"] }, + }); + + archive.archive(id, &written, at(10)).await.unwrap(); + + assert_eq!(archive.versions(id).await.unwrap()[0].doc, written); +} + +/// One canonicalisation serves both ports: the back-up copy of a passport and the +/// archived version of that same document carry the same hash. +#[tokio::test] +async fn a_back_up_copy_and_an_archived_version_of_one_document_share_a_hash() { + let passport = crate::test_support::sample_passport(); + let backed_up = InMemoryBackup::new().store(&passport, 10).await.unwrap(); + + let archived = InMemoryArchive::new() + .archive( + passport.id, + &serde_json::to_value(&passport).unwrap(), + at(10), + ) + .await + .unwrap(); + + assert_eq!(archived.content_hash, backed_up.content_hash); +} diff --git a/crates/dpp-domain/src/ports/archive/version.rs b/crates/dpp-domain/src/ports/archive/version.rs new file mode 100644 index 00000000..a6dfe808 --- /dev/null +++ b/crates/dpp-domain/src/ports/archive/version.rs @@ -0,0 +1,28 @@ +//! [`ArchivedVersion`] — one archived version of a passport. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::passport::PassportId; + +/// A passport as it stood until the moment a change replaced it. +/// +/// The version was current from the moment the previous one was superseded, or +/// from the passport's creation for the first, until +/// [`superseded_at`](Self::superseded_at), and no longer. The interval is +/// half-open: at `superseded_at` itself the next version is the current one. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedVersion { + /// The passport this is a version of. + pub passport_id: PassportId, + /// The passport as it stood, whole and as written. Never a typed + /// `Passport`: see the module docs for why an archive keeps the document + /// rather than reading it through a struct. + pub doc: serde_json::Value, + /// The instant a change replaced this version. + pub superseded_at: DateTime, + /// SHA-256 of the RFC 8785 canonical form of [`doc`](Self::doc), as lower-case + /// hexadecimal. The same definition `BackupReceipt::content_hash` uses. + pub content_hash: String, +} diff --git a/crates/dpp-domain/src/ports/backup/mod.rs b/crates/dpp-domain/src/ports/backup/mod.rs index 89dbb182..0daec78c 100644 --- a/crates/dpp-domain/src/ports/backup/mod.rs +++ b/crates/dpp-domain/src/ports/backup/mod.rs @@ -22,7 +22,9 @@ //! so a history is not expressible here whoever owes it. While the two shared //! a word, a reader could satisfy themselves that wiring this port had ticked //! clause 4.2. Wiring it cannot, and that is a statement about this interface -//! rather than about anybody's duties. +//! rather than about anybody's duties. A history is expressible through +//! [`ArchivedVersionPort`](crate::ports::archive::ArchivedVersionPort), which a +//! provider implements alongside this port. //! //! The Regulation supplies the name. ESPR Art. 10(4) says *back-up copy*, so that is //! what this is called, and "archiving" is left to mean only the thing the @@ -32,10 +34,14 @@ //! The obligation is **ESPR Art. 10(4)**: the economic operator "shall make available //! a back-up copy of the digital product passport through a digital product //! passport service provider", which **Art. 2(32)** defines as "an independent -//! third-party authorised by the economic operator". The period is **Annex -//! III(i)** — "at least the expected lifetime of a specific product" — delegated -//! per product group. **Annex III(l)** makes the provider's reference a passport -//! data element. +//! third-party authorised by the economic operator". The period is **Art. +//! 9(2)(i)** — "at least the expected lifetime of a specific product" — which each +//! delegated act sets for its product group. Annex III point (i) lists unique +//! facility identifiers, so it is not where the period comes from. **Annex +//! III(l)** makes the provider's reference a passport data element. +//! +//! ✅ COMPLIANCE-PIN: Reg. (EU) 2024/1781, Art. 9(2)(i) and Annex III points (i) +//! and (l), read in the consolidated text of 28.6.2024 on 2026-10-06. //! //! Two consequences worth stating, because both have been got wrong before. //! *Independent third party* means an operator's own storage does not discharge diff --git a/crates/dpp-domain/src/ports/backup/port.rs b/crates/dpp-domain/src/ports/backup/port.rs index 8c85e4b3..cc8755a6 100644 --- a/crates/dpp-domain/src/ports/backup/port.rs +++ b/crates/dpp-domain/src/ports/backup/port.rs @@ -55,7 +55,9 @@ pub trait BackupCopyPort: Send + Sync { /// merely because it is not ESPR Art. 10(4)'s. It is simply not expressible /// here: no method on this port takes or returns a series. A caller that /// reads this method as discharging clause 4.2 has read a promise this - /// trait cannot make. + /// trait cannot make. A history is held through + /// [`ArchivedVersionPort`](crate::ports::archive::ArchivedVersionPort), which a + /// provider implements alongside this port. async fn update(&self, passport: &Passport) -> Result; /// Verify that the provider holds an intact copy of the passport. diff --git a/crates/dpp-domain/src/ports/backup/receipt.rs b/crates/dpp-domain/src/ports/backup/receipt.rs index acb52c43..e66ba0ab 100644 --- a/crates/dpp-domain/src/ports/backup/receipt.rs +++ b/crates/dpp-domain/src/ports/backup/receipt.rs @@ -19,7 +19,10 @@ pub struct BackupReceipt { pub backup_id: String, /// The passport ID of the backed-up record. pub passport_id: PassportId, - /// Cryptographic hash (SHA-256) of the stored payload for integrity verification. + /// SHA-256 of the RFC 8785 (JCS) canonical form of the stored passport, as + /// lower-case hexadecimal, for integrity verification. The archive port + /// defines its hash the same way, so a version held in either carries one + /// hash: see [`ArchivedVersionPort`](crate::ports::archive::ArchivedVersionPort). pub content_hash: String, /// Timestamp when the provider accepted the record. pub stored_at: DateTime, diff --git a/crates/dpp-domain/src/ports/backup/stub.rs b/crates/dpp-domain/src/ports/backup/stub.rs index 05fe834d..fb5eac1b 100644 --- a/crates/dpp-domain/src/ports/backup/stub.rs +++ b/crates/dpp-domain/src/ports/backup/stub.rs @@ -5,7 +5,6 @@ use crate::error::DppError; use crate::passport::{Passport, PassportId}; use async_trait::async_trait; use chrono::Utc; -use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Mutex; @@ -20,10 +19,13 @@ impl InMemoryBackup { } } - fn hash_passport(passport: &Passport) -> String { - let json = serde_json::to_vec(passport).unwrap_or_default(); - let digest = Sha256::digest(&json); - hex::encode(digest) + /// The hash both ports define: SHA-256 of the RFC 8785 canonical form of the + /// document, as lower-case hexadecimal. + fn hash_passport(passport: &Passport) -> Result { + let document = + serde_json::to_value(passport).map_err(|e| DppError::Serialisation(e.to_string()))?; + dpp_rules::canonical::content_hash(&document) + .map_err(|e| DppError::Serialisation(e.to_string())) } } @@ -42,7 +44,7 @@ impl BackupCopyPort for InMemoryBackup { ) -> Result { let now = Utc::now(); let retention_until = retention_deadline(now, retention_years); - let hash = Self::hash_passport(passport); + let hash = Self::hash_passport(passport)?; let receipt = BackupReceipt { backup_id: format!("BACKUP-{}", uuid::Uuid::now_v7()), passport_id: passport.id, @@ -56,10 +58,11 @@ impl BackupCopyPort for InMemoryBackup { } async fn update(&self, passport: &Passport) -> Result { + let hash = Self::hash_passport(passport)?; let mut store = self.store.lock().unwrap(); if let Some((stored, receipt)) = store.get_mut(&passport.id) { *stored = passport.clone(); - receipt.content_hash = Self::hash_passport(passport); + receipt.content_hash = hash; Ok(receipt.clone()) } else { Err(DppError::NotFound(format!( diff --git a/crates/dpp-domain/src/ports/mod.rs b/crates/dpp-domain/src/ports/mod.rs index d0f3cd81..a1f6e3c1 100644 --- a/crates/dpp-domain/src/ports/mod.rs +++ b/crates/dpp-domain/src/ports/mod.rs @@ -1,5 +1,6 @@ //! Port traits defining the core/platform boundary — one port per infrastructure concern. +pub mod archive; pub mod backup; pub mod compliance; mod ghosts; diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 9277d683..4352fd20 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -164,6 +164,7 @@ Trait definitions that downstream projects implement against their own infrastru | `IdentityPort` | yes | Sign and verify passport JWS | | `PluginHost` | no | Dispatch to Wasm product group plugins | | `BackupCopyPort` | yes | The ESPR Art. 10(4) third-party back-up copy | +| `ArchivedVersionPort` | yes | The EN 18221 clause 4.2 archive of a passport's historical versions | | `RegistrySyncPort` | yes | EU Central Registry registration and status sync | | `SealPort` | yes | eIDAS qualified electronic seal (ESPR Art. 13 / eIDAS 910/2014) | diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index a669bff1..efa80afb 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -164,12 +164,13 @@ trait IdentityPort // Sign and verify JWS // Plugins trait PluginHost // Dispatch to Wasm plugins -// Back-up copy & registry & sealing +// Back-up copy & archive & registry & sealing trait BackupCopyPort // The ESPR Art. 10(4) third-party back-up copy +trait ArchivedVersionPort // The EN 18221 clause 4.2 archive of historical versions trait RegistrySyncPort // EU Central Registry registration / status sync trait SealPort // eIDAS qualified electronic seal (ESPR Art. 13) ``` -`PassportRepository`, `IdentityPort`, `BackupCopyPort`, `RegistrySyncPort`, and `SealPort` are `async`. The compliance and plugin traits are sync — they must work in `no_std` and `wasm32` contexts. +`PassportRepository`, `IdentityPort`, `BackupCopyPort`, `ArchivedVersionPort`, `RegistrySyncPort`, and `SealPort` are `async`. The compliance and plugin traits are sync — they must work in `no_std` and `wasm32` contexts. `dpp-vc` provides `LocalIdentityService`, a concrete `IdentityPort` implementation backed by `dpp-crypto`'s local `KeyStore`. Anyone who implements the remaining traits against their own infrastructure has a complete, standard-compliant DPP system. diff --git a/docs/architecture/PORTS.md b/docs/architecture/PORTS.md index 0a547de1..eed97062 100644 --- a/docs/architecture/PORTS.md +++ b/docs/architecture/PORTS.md @@ -10,7 +10,8 @@ drifts the moment another port lands). CI enforces agreement: the test | Module | Trait(s) | Concern | |---|---|---| -| `backup` | `BackupCopyPort` | The third-party **back-up copy** of ESPR **Art. 10(4)**, lodged with the **Art. 2(32)** independent provider for the **Annex III(i)** availability period — *not* ESPR Art. 13, which is the registry. Separate from EN 18221 clause 4.2 archiving by **shape, not by actor**: clause 4.2 expects the back-up provider to hold archived versions too, so a provider may owe a history — but nothing on this port takes or returns a series, so none is expressible here. | +| `archive` | `ArchivedVersionPort` | The **archive** of a live passport's historical versions, EN 18221 clause 4.2: the version a change replaces, kept append-only for the passport's lifetime, each with a content hash. Separate from `backup` by **shape, not by actor**: this port holds a *series of versions of one record*, and a back-up provider implements it alongside `BackupCopyPort`, as the main store does. It returns whole documents and applies no disclosure policy, which the caller does. | +| `backup` | `BackupCopyPort` | The third-party **back-up copy** of ESPR **Art. 10(4)**, lodged with the **Art. 2(32)** independent provider for the **Art. 9(2)(i)** availability period — *not* ESPR Art. 13, which is the registry. Separate from EN 18221 clause 4.2 archiving by **shape, not by actor**: this port holds *one copy of one record*, so a history is not expressible here, and is held through `archive` instead. Clause 4.2 expects the back-up provider to hold archived versions too, so a provider implements both. | | `compliance` | `ComplianceRegistry`, `ComplianceStrategy` | Product group dispatch + per-product group compliance strategy (**two traits**). | | `identity` | `IdentityPort` | Operator-key sign/verify (Ed25519/JWS). | | `passport_repo` | `PassportRepository` | Passport persistence. | @@ -18,7 +19,7 @@ drifts the moment another port lands). CI enforces agreement: the test | `registry_sync` | `RegistrySyncPort` | EU Central Registry registration/status sync (ESPR Art. 13). | | `seal` | `SealPort` | eIDAS qualified electronic seal (eIDAS 910/2014). | -**Count today: 7 port modules, 8 `pub trait`s** (compliance carries two). Prefer +**Count today: 8 port modules, 9 `pub trait`s** (compliance carries two). Prefer naming the modules over asserting a count. ### Adjacent seams (deliberately *not* in `ports/`) @@ -31,6 +32,7 @@ so the inventory is complete, and are excluded from the machine block below. ``` +archive backup compliance identity From 3322a14be1838632047f9e5f5d5609f2dd7b93f1 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Wed, 7 Oct 2026 13:37:03 +0200 Subject: [PATCH 2/2] docs(changelog): file hash change as breaking --- CHANGELOG.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbb8898..4d5b3649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,22 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for recycled-content shares and `co2ePerUnitKg` for EV, LMT and industrial batteries. +- **`BackupReceipt::content_hash` has a definition, and the in-memory back-up + follows it.** The hash is SHA-256 of the RFC 8785 canonical form of the + passport, as lower-case hexadecimal, which is how the new archive port + defines its own. Until now the field said only "SHA-256 of the stored + payload", and `InMemoryBackup` hashed plain `serde_json` output. That differs + from the canonical bytes for most documents, so a hash computed the old way + no longer matches the receipt for the same, unchanged passport, and + `BackupCopyPort::verify` reports a mismatch. `sha2` and `hex` are no longer + optional dependencies of `dpp-domain`, so the `sha2` and `hex` features they + implied are gone. (#387) + + **Migration:** recompute any expected hash taken from an earlier + `InMemoryBackup` receipt, or from a digest of raw JSON, before passing it to + `verify`. An adapter that fills `content_hash` hashes the canonical form. + Remove `sha2` and `hex` from any `dpp-domain` feature list. + ### Added - **A port for the archive of a passport's historical versions.** `ports::archive` @@ -163,15 +179,11 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for the most up-to-date version. Holding history there is what the presumption of conformity under the standard costs, and the docs say so rather than calling it a legal requirement. - - **The content hash is now defined, for both ports:** SHA-256 of the RFC 8785 - canonical form, as lower-case hexadecimal. `BackupReceipt::content_hash` had - no definition, and the in-memory back-up hashed plain `serde_json` output. It - now follows the same definition, so one document carries one hash in either - port. + - **One document carries one hash in either port:** SHA-256 of the RFC 8785 + canonical form, as lower-case hexadecimal, the definition + `BackupReceipt::content_hash` now has (see Breaking). - `InMemoryArchive` ships with the `test-utils` feature, which now enables - `dpp-rules/bundle` for the canonical hash. `sha2` and `hex` are no longer - optional dependencies of `dpp-domain`, so the `sha2` and `hex` features that - they implied are gone. + `dpp-rules/bundle` for the canonical hash. Not covered: a bound on how far behind a back-up may lag (clause 4.5), though the receipt's `archived_at` against `superseded_at` is the means to measure it; and