dpp-core 0.20.0 ships two lineage rules. The engine now runs one of them (check_life_status_consistency, wired in #313). The other is still uncalled:
dpp_rules::lineage::check_derivation_consent(
passport_operator_did: &str,
edges: &[DerivationEdge],
) -> Vec<ConsentFinding>
Why it matters
A derivation edge pins its target with a hash. That proves the target has not been modified; it says nothing about whether the target's operator agreed to the relationship. Anyone can publish a passport claiming to derive from anyone else's product.
For a bill of materials that is tolerable — over-claiming a supplier is a commercial problem, and a component reference is openly a claim by the assembler. For second life it is not: Reg. (EU) 2023/1542 Art. 77(7) moves regulatory responsibility to the operator placing the second-life unit on the market, and responsibility must not be assignable by unilateral assertion.
So today, this node will accept, store, sign and publish a passport asserting that it took over responsibility for someone else's battery, with nothing anywhere checking that the other operator agreed.
Why it is not just wiring
The rule is explicit that the caller correlates and the rule only checks. It needs, per edge, the transfer of responsibility that consents to it — and the engine cannot currently produce that:
- A
derivedFrom edge identifies its target by URI.
TransferStore::get_chain is keyed by local PassportId.
- The predecessor belongs to another operator, on another node.
So correlation is a cross-operator fetch of that node's transfer chain. That opens real questions rather than an afternoon's plumbing:
- Resolution path. Is the transfer chain fetchable at all from a passport URI? There is no public route serving it today —
GET /dpp/{dppId}/history and the transfer routes are all Bearer-authenticated, and the predecessor's operator has no reason to hold our API key. Exposing a chain publicly is a disclosure decision, not a plumbing one.
- When it runs. At create (refuse), at publish (refuse), or as a lint finding (report)? Publish is the moment the claim becomes signed and permanent, which argues for there; but publish currently performs no outbound fetch, and making it depend on another operator's availability is a real change to its failure modes.
- Fail-open vs fail-closed. The rule reports an edge whose caller found no transfer as unconsented — "no evidence was found" rather than "none exists". If the fetch times out, those are indistinguishable from here, and treating a network failure as a consent defect would be wrong.
- SSRF and caching. Same outbound guard as
verify_tree's fetch, and the answer is stable once a transfer completes, so it is cacheable — but a pending transfer is not.
Note on what the evidence actually is
Worth stating because it is easy to get backwards: exactly one signature in a transfer record is a counterparty's own — the outgoing operator's authorisation. The acceptance half is the hosting node's attestation that the acceptance step ran, not a signature by the incoming operator.
That asymmetry is harmless here. The consent this rule needs is the predecessor's, and in a second-life transfer the predecessor's operator is the outgoing party — so the half that is a genuine party signature is exactly the half that matters. TransferEvidence::accepted is still carried and checked, because an abandoned or rejected handover should not support a live claim, but it is not a second party's signature and the design must not lean on it as one.
Suggested first step
Decide (1) and (2) before writing code. If the chain is not publicly resolvable, this rule cannot run cross-operator at all, and the honest interim is a lint finding on the local case only — a derivation edge pointing at a passport this node itself hosts — with the cross-operator case explicitly reported as unverifiable rather than silently passing.
dpp-core0.20.0 ships two lineage rules. The engine now runs one of them (check_life_status_consistency, wired in #313). The other is still uncalled:Why it matters
A derivation edge pins its target with a hash. That proves the target has not been modified; it says nothing about whether the target's operator agreed to the relationship. Anyone can publish a passport claiming to derive from anyone else's product.
For a bill of materials that is tolerable — over-claiming a supplier is a commercial problem, and a component reference is openly a claim by the assembler. For second life it is not: Reg. (EU) 2023/1542 Art. 77(7) moves regulatory responsibility to the operator placing the second-life unit on the market, and responsibility must not be assignable by unilateral assertion.
So today, this node will accept, store, sign and publish a passport asserting that it took over responsibility for someone else's battery, with nothing anywhere checking that the other operator agreed.
Why it is not just wiring
The rule is explicit that the caller correlates and the rule only checks. It needs, per edge, the transfer of responsibility that consents to it — and the engine cannot currently produce that:
derivedFromedge identifies its target by URI.TransferStore::get_chainis keyed by localPassportId.So correlation is a cross-operator fetch of that node's transfer chain. That opens real questions rather than an afternoon's plumbing:
GET /dpp/{dppId}/historyand the transfer routes are allBearer-authenticated, and the predecessor's operator has no reason to hold our API key. Exposing a chain publicly is a disclosure decision, not a plumbing one.verify_tree's fetch, and the answer is stable once a transfer completes, so it is cacheable — but a pending transfer is not.Note on what the evidence actually is
Worth stating because it is easy to get backwards: exactly one signature in a transfer record is a counterparty's own — the outgoing operator's authorisation. The acceptance half is the hosting node's attestation that the acceptance step ran, not a signature by the incoming operator.
That asymmetry is harmless here. The consent this rule needs is the predecessor's, and in a second-life transfer the predecessor's operator is the outgoing party — so the half that is a genuine party signature is exactly the half that matters.
TransferEvidence::acceptedis still carried and checked, because an abandoned or rejected handover should not support a live claim, but it is not a second party's signature and the design must not lean on it as one.Suggested first step
Decide (1) and (2) before writing code. If the chain is not publicly resolvable, this rule cannot run cross-operator at all, and the honest interim is a lint finding on the local case only — a derivation edge pointing at a passport this node itself hosts — with the cross-operator case explicitly reported as unverifiable rather than silently passing.