Skip to main content

dpp_domain/transfer/
record.rs

1//! [`TransferRecord`] — a single transfer-of-responsibility event.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::error::TransferError;
8use super::status::TransferStatus;
9use crate::operator::ResponsibleOperator;
10use crate::passport::PassportId;
11
12/// The reason for a transfer of DPP responsibility.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15#[non_exhaustive]
16pub enum TransferReason {
17    /// Product sold to a new economic operator for market placement.
18    Sale,
19    /// Product returned to the supply chain (e.g. customer return).
20    Return,
21    /// Product sent for remanufacturing.
22    Remanufacturing,
23    /// Product adapted for a different purpose.
24    Repurposing,
25    /// Product prepared for resale as second-hand.
26    PreparationForReuse,
27    /// Waste product prepared so that it can serve a different purpose.
28    ///
29    /// Distinct from [`Self::Repurposing`], and the difference is the input,
30    /// not the outcome: Reg. (EU) 2023/1542 Art. 3(30) defines this over "a
31    /// waste battery, or parts thereof", while Art. 3(31) defines repurposing
32    /// over "a battery, that is not a waste battery". Art. 77(7) names all
33    /// four second-life operations, and this one was the only one of them
34    /// this enum could not express.
35    PreparationForRepurposing,
36    /// The battery has become waste, and responsibility moves with it.
37    ///
38    /// ✅ COMPLIANCE-PIN: EU 2023/1542, Art. 77(7) second subparagraph
39    /// (OJ L 191, 28.7.2023, p. 73) — responsibility passes to the producer, to
40    /// the producer responsibility organisation where one is appointed under
41    /// Art. 57(1), or to the waste management operator selected under
42    /// Art. 57(8). The same subparagraph mandates **no new passport**, which is
43    /// what distinguishes this handover from the four second-life operations.
44    ///
45    /// Deliberately **not** folded into
46    /// [`SecondLifeOperation`](crate::passport::SecondLifeOperation): that is a
47    /// different vocabulary. Those four describe second-life *processing* that
48    /// produces a new unit, whereas this records a waste handover on a record
49    /// that continues. Folding them would let the lineage-consent rule match a
50    /// waste handover against a derivation edge, which is not the consent that
51    /// rule is looking for.
52    WasteHandover,
53    /// Product imported into the EU by a new importer.
54    Import,
55    /// Original operator became insolvent; responsibilities assumed by successor.
56    InsolvencySuccession,
57}
58
59impl TransferReason {
60    /// Every reason this build models, for exhaustive iteration.
61    ///
62    /// `TransferReason` is `#[non_exhaustive]`, so a consumer outside this crate
63    /// cannot enumerate it, and one publishing an API description has to. See
64    /// [`crate::seal::SealFormat::ALL`] for the same contract: a reason
65    /// added later is deliberately not covered until it is added here.
66    pub const ALL: &'static [Self] = &[
67        Self::Sale,
68        Self::Return,
69        Self::Remanufacturing,
70        Self::Repurposing,
71        Self::PreparationForReuse,
72        Self::PreparationForRepurposing,
73        Self::WasteHandover,
74        Self::Import,
75        Self::InsolvencySuccession,
76    ];
77
78    /// The stable wire form, for payloads that carry the reason as a string.
79    ///
80    /// Spelled out rather than derived from `Serialize` so that renaming a
81    /// variant cannot silently change what a registry receives.
82    pub fn wire_str(&self) -> &'static str {
83        match self {
84            Self::Sale => "sale",
85            Self::Return => "return",
86            Self::Remanufacturing => "remanufacturing",
87            Self::Repurposing => "repurposing",
88            Self::PreparationForReuse => "preparationForReuse",
89            Self::PreparationForRepurposing => "preparationForRepurposing",
90            Self::WasteHandover => "wasteHandover",
91            Self::Import => "import",
92            Self::InsolvencySuccession => "insolvencySuccession",
93        }
94    }
95}
96
97/// A single transfer-of-responsibility event in the DPP lifecycle.
98///
99/// Recorded in two steps — the outgoing operator authorises the handover, and
100/// the hosting node then attests that the incoming operator accepted it. The
101/// two are **not** symmetric: see [`TransferRecord::from_signature`] and
102/// [`TransferRecord::node_acceptance_attestation`], only the first of which is
103/// a party's own signature.
104///
105/// The chain this builds is an auditable local record of what the node was
106/// told. It is not proof of who holds the obligations: under Implementing
107/// Regulation (EU) 2026/1778 Art. 6a that is the EU registry's record, between
108/// actors whose identity is verified by eIDAS means under its Arts. 4-5.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct TransferRecord {
112    /// Unique identifier for this transfer event.
113    pub transfer_id: Uuid,
114    /// The passport being transferred.
115    pub passport_id: PassportId,
116    /// The outgoing (previous) responsible operator.
117    pub from_operator: ResponsibleOperator,
118    /// The incoming (new) responsible operator.
119    pub to_operator: ResponsibleOperator,
120    /// The reason for this transfer.
121    pub reason: TransferReason,
122    /// Compact JWS signature from the outgoing operator, signing over the
123    /// transfer payload to authorise the handover.
124    ///
125    /// Produced by the node that hosts the outgoing operator, using that
126    /// operator's key — so in the managed single-node model this genuinely is
127    /// the outgoing party's signature. Its counterpart below is not.
128    pub from_signature: Option<String>,
129    /// This **node's** attestation that the incoming operator accepted the
130    /// handover. **Not a signature by the incoming operator.**
131    ///
132    /// The incoming operator is a party with no key on this node, so nothing
133    /// here can be signed by them. What is recorded is a JWS over the same
134    /// [`Self::signing_payload`], produced by the same key that produced
135    /// [`Self::from_signature`], at the moment the acceptance step ran. Because
136    /// the payload is RFC 8785 canonical, the JWS header carries no nonce or
137    /// timestamp, and Ed25519 is deterministic, the two values are **byte-
138    /// identical** in the managed model. The second one therefore carries
139    /// exactly one bit — that the acceptance step ran — and `completed_at`
140    /// carries the same bit with a timestamp attached.
141    ///
142    /// It is named for what it is because the previous name, `to_signature`,
143    /// was not. That name invited every consumer — including the registry
144    /// notification built from this record — to read the value as the incoming
145    /// operator's own authorisation, which is a claim no node can make about a
146    /// counterparty whose key it does not hold.
147    ///
148    /// **The authoritative record of who holds the obligations is the EU
149    /// registry**, under Implementing Regulation (EU) 2026/1778 Art. 6a, between
150    /// actors whose identity is verified by eIDAS means under its Arts. 4–5.
151    /// A chain of `did:web` identifiers is not evidence of that standing. See
152    /// `docs/regulatory/COMPLIANCE.md` § Transfer-of-Responsibility Article Pin.
153    ///
154    /// `alias` keeps chains stored under the old key readable: without it every
155    /// already-completed transfer would deserialize with this field `None` and
156    /// [`Self::is_complete`] would start answering `false` for handovers that
157    /// did complete.
158    #[serde(alias = "toSignature")]
159    pub node_acceptance_attestation: Option<String>,
160    /// Timestamp when the transfer was initiated.
161    pub initiated_at: DateTime<Utc>,
162    /// Timestamp when the transfer was completed — the outgoing operator had
163    /// authorised it and the acceptance step had run.
164    /// `None` if the transfer is still pending acceptance.
165    pub completed_at: Option<DateTime<Utc>>,
166    /// Timestamp when the incoming operator explicitly rejected the transfer.
167    /// Set by [`TransferRecord::reject`]; makes the record terminal.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub rejected_at: Option<DateTime<Utc>>,
170    /// Timestamp when the outgoing operator cancelled the transfer.
171    /// Set by [`TransferRecord::cancel`]; makes the record terminal.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub cancelled_at: Option<DateTime<Utc>>,
174    /// Free-text notes (e.g. conditions, regulatory references).
175    pub notes: Option<String>,
176}
177
178impl TransferRecord {
179    /// The canonical content signed over at both steps: the immutable core
180    /// of the transfer, excluding the signatures themselves and the lifecycle
181    /// timestamps set *after* signing (`completed_at`/`rejected_at`/`cancelled_at`).
182    ///
183    /// [`Self::from_signature`] and [`Self::node_acceptance_attestation`] are
184    /// both JWS over the JCS canonicalisation of this value, so both bind the
185    /// same immutable handover terms and tampering any bound field invalidates
186    /// both.
187    ///
188    /// Note what that implies: the same payload signed by the same key yields
189    /// the same JWS, so in the managed single-node model the two values are
190    /// byte-identical. That is a property of this design rather than a defect —
191    /// the second records that the acceptance step ran, and nothing more.
192    #[must_use]
193    pub fn signing_payload(&self) -> serde_json::Value {
194        serde_json::json!({
195            "transferId": self.transfer_id,
196            "passportId": self.passport_id,
197            "fromOperator": self.from_operator,
198            "toOperator": self.to_operator,
199            "reason": self.reason,
200            "initiatedAt": self.initiated_at,
201        })
202    }
203
204    /// Determine the current status of this transfer.
205    ///
206    /// Terminal states (`Rejected`, `Cancelled`) take priority over signatures,
207    /// so a cancelled transfer that already had the from_signature still reports
208    /// `Cancelled` rather than `Initiated`.
209    pub fn status(&self) -> TransferStatus {
210        if self.rejected_at.is_some() {
211            return TransferStatus::Rejected;
212        }
213        if self.cancelled_at.is_some() {
214            return TransferStatus::Cancelled;
215        }
216        match (
217            &self.from_signature,
218            &self.node_acceptance_attestation,
219            &self.completed_at,
220        ) {
221            (Some(_), Some(_), Some(_)) => TransferStatus::Completed,
222            (Some(_), Some(_), None) => TransferStatus::Accepted,
223            _ => TransferStatus::Initiated,
224        }
225    }
226
227    /// Returns `true` if the handover was authorised, accepted and finalised.
228    pub fn is_complete(&self) -> bool {
229        self.from_signature.is_some()
230            && self.node_acceptance_attestation.is_some()
231            && self.completed_at.is_some()
232    }
233
234    /// The incoming operator explicitly rejects the transfer.
235    ///
236    /// Only valid from `Initiated` state. After rejection the record is terminal;
237    /// a new transfer may be initiated on the chain.
238    pub fn reject(&mut self) -> Result<(), TransferError> {
239        let s = self.status();
240        if s != TransferStatus::Initiated {
241            return Err(TransferError::InvalidState {
242                current: s,
243                action: "reject".into(),
244            });
245        }
246        self.rejected_at = Some(Utc::now());
247        Ok(())
248    }
249
250    /// The outgoing operator cancels the transfer before it completes.
251    ///
252    /// Valid from `Initiated` or `Accepted` state. After cancellation the
253    /// record is terminal; a new transfer may be initiated on the chain.
254    pub fn cancel(&mut self) -> Result<(), TransferError> {
255        match self.status() {
256            TransferStatus::Initiated | TransferStatus::Accepted => {
257                self.cancelled_at = Some(Utc::now());
258                Ok(())
259            }
260            s => Err(TransferError::InvalidState {
261                current: s,
262                action: "cancel".into(),
263            }),
264        }
265    }
266
267    /// Mark the transfer as completed once it is authorised and accepted.
268    ///
269    /// Only valid from `Accepted` state ([`Self::from_signature`] and
270    /// [`Self::node_acceptance_attestation`] both present, no `completed_at`
271    /// yet). This is the final step before the incoming operator becomes the
272    /// current responsible operator **in this node's chain** — which is a local
273    /// record, not the registry's determination of who holds the obligations.
274    pub fn complete(&mut self) -> Result<(), TransferError> {
275        let s = self.status();
276        if s != TransferStatus::Accepted {
277            return Err(TransferError::InvalidState {
278                current: s,
279                action: "complete".into(),
280            });
281        }
282        self.completed_at = Some(Utc::now());
283        Ok(())
284    }
285}