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}