Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,61 @@ 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`
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.
- **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.

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
Expand Down Expand Up @@ -244,6 +297,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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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) |

Expand Down
15 changes: 6 additions & 9 deletions crates/dpp-domain/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions crates/dpp-domain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
94 changes: 94 additions & 0 deletions crates/dpp-domain/src/ports/archive/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
69 changes: 69 additions & 0 deletions crates/dpp-domain/src/ports/archive/port.rs
Original file line number Diff line number Diff line change
@@ -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<Utc>,
) -> Result<ArchiveReceipt, DppError>;

/// 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<Vec<ArchivedVersion>, 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<Utc>,
) -> Result<Option<ArchivedVersion>, DppError>;
}
24 changes: 24 additions & 0 deletions crates/dpp-domain/src/ports/archive/receipt.rs
Original file line number Diff line number Diff line change
@@ -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<Utc>,
/// 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<Utc>,
}
Loading
Loading