dpp-domain 0.21.0

EU Digital Product Passport domain types, port traits, and per-field disclosure policy
Documentation
//! [`Disclosure`] — how restricted a field is, and the key a disclosure set gets.

use serde::{Deserialize, Serialize};

/// How restricted a field is — the counterpart to [`Audience`](crate::disclosure::Audience).
///
/// Named for the Annex XIII point each class corresponds to, and kept
/// product group-agnostic so non-battery product groups reuse the same vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Disclosure {
    /// Publicly accessible. Annex XIII point 1.
    Public,
    /// Detailed composition, dismantling information, safety measures.
    /// Annex XIII point 2 — visible to **both** non-public audiences.
    Restricted,
    /// Conformity evidence: results of test reports. Annex XIII point 3 —
    /// authorities only.
    Conformity,
    /// Information and data relating to an **individual** item: use history,
    /// cycle counts, negative events, state of health, status. Annex XIII
    /// point 4 — legitimate interest only, and explicitly **not** authorities.
    Individual,
}

/// Disclosure class of every top-level passport field that is not public.
///
/// **The single source for this fact.** `Passport::redact` and the crypto
/// layer's `ProductGroupAccessPolicy::passport_default()` both read it, because they
/// previously each carried their own copy and drifted: the policy classified
/// `lintResult` as restricted while `redact` never removed it, so a public view
/// built through the domain path disclosed it.
///
/// Fields absent from this list are [`Disclosure::Public`].
pub const PASSPORT_FIELD_DISCLOSURE: &[(&str, Disclosure)] = &[
    ("batchId", Disclosure::Restricted),
    // The item-level twin of `batchId`, and classified to match it.
    //
    // NOT `Individual`: that tier is "legitimate interest only, and
    // explicitly **not** authorities", and a market surveillance authority
    // holding a unit has to be able to identify it. `Restricted` reaches both
    // non-public audiences, which is the shape this needs.
    //
    // NOT `Public` either: a per-unit serial on an anonymous view is what
    // lets one physical object be tracked across readers, and keeping the
    // printed label from leaking per-unit facts is the same concern that moved
    // the carrier serial off the UUIDv7 timestamp bytes in 0.11.0.
    //
    // That is the default and not a rule of law: where a product group's own
    // legislation makes the identifying batch or serial public, its schema
    // opens it — see `GROUP_OPENABLE_ENVELOPE_FIELDS`.
    ("serialNumber", Disclosure::Restricted),
    // Annex XIII point 4(c) of Reg. (EU) 2023/1542, and point 4's heading is
    // "INFORMATION AND DATA RELATING TO AN INDIVIDUAL BATTERY ACCESSIBLE ONLY TO
    // PERSONS WITH A LEGITIMATE INTEREST". Classified rather than left to
    // `default_disclosure`, which is `Public` — an individual unit's life status
    // is exactly what that tier exists to keep off the anonymous view.
    ("lifeStatus", Disclosure::Individual),
    // Advisory plausibility output, re-computable after publish and carrying
    // free-text findings about our own data quality — operator- and
    // auditor-facing, not consumer-facing.
    ("lintResult", Disclosure::Restricted),
    // The four proof fields. `Passport::redact` strips these unconditionally —
    // see `PASSPORT_PROOF_FIELDS` for why no class can be the whole answer. They
    // are classed here as well, as defence in depth, so a consumer driving
    // `filter_by_audience` directly with `passport_default()` fails safe instead
    // of serving a proof to the public. `Conformity` is the most restrictive
    // class a single entry can carry.
    ("jwsSignature", Disclosure::Conformity),
    ("publicJwsSignature", Disclosure::Conformity),
    ("disclosureSignatures", Disclosure::Conformity),
    ("seal", Disclosure::Conformity),
    ("retentionLocked", Disclosure::Conformity),
];

/// The envelope fields a product group's schema may open to the public, and
/// no others.
///
/// # Why a product group can open these at all
///
/// Access to passport data is decided per product group. Regulation (EU)
/// 2024/1781 Art. 10(1)(g) regulates it *"with the specific access rights at
/// product group level as specified in the applicable delegated act"*, and a
/// group's own legislation can put an identifier on the public side. Batteries
/// do: Art. 38(6) of Regulation (EU) 2023/1542 has a battery bear *"a model
/// identification and batch or serial number, or product number or another
/// element allowing their identification"*, Annex VI Part A point 2 puts that
/// on the label, and Annex XIII point 1(a) makes Annex VI Part A publicly
/// accessible in the passport. A universal `Restricted` on the envelope would
/// withhold what that act requires to be shown.
///
/// # Why only these two
///
/// An opening is declared by a schema, and a schema is the product group's to
/// write — so the list is the whole of what one can reach. It holds the two
/// identity fields a product group's law can require to be public, and nothing
/// that carries a proof or another audience's data. A schema naming anything
/// else is refused when its policy is built, rather than opening it.
///
/// The opening applies to the envelope key itself, at the top of the document,
/// never to a field of the same name nested anywhere else.
pub const GROUP_OPENABLE_ENVELOPE_FIELDS: &[&str] = &["batchId", "serialNumber"];

impl Disclosure {
    /// How many of the three audiences may see this class.
    ///
    /// Not an ordering of the lattice — there isn't one. It is the only totally
    /// ordered thing the lattice offers, which is what a deterministic tie-break
    /// needs.
    const fn audience_count(self) -> u8 {
        match self {
            // Everyone.
            Self::Public => 3,
            // Legitimate interest and authorities, not the public.
            Self::Restricted => 2,
            // Exactly one audience each, and not the same one.
            Self::Conformity | Self::Individual => 1,
        }
    }

    /// The more restrictive of two classes — the one fewer audiences may see.
    ///
    /// Exists so that an ambiguous lookup resolves the safe way and resolves it
    /// **identically every time**. Used by
    /// [`ProductGroupAccessPolicy::disclosure_for_field`](crate::access::ProductGroupAccessPolicy::disclosure_for_field)
    /// when two normalized-equal keys both match.
    ///
    /// `Conformity` and `Individual` are genuinely incomparable — Art. 77(2)
    /// gives each to one audience, and neither audience contains the other, so
    /// no class means "withheld from both". The tie-break returns `Individual`,
    /// which is a choice rather than a derivation, and it is only ever reached
    /// by a policy that declares one field name in both classes. That is an
    /// authoring error a schema cannot commit: `access::tests` rejects it at
    /// build time.
    #[must_use]
    pub const fn most_restrictive(self, other: Self) -> Self {
        if other.audience_count() <= self.audience_count() {
            other
        } else {
            self
        }
    }

    /// The stable wire token for this class, used to build a disclosure-set key.
    ///
    /// Deliberately not `Serialize`-derived: this string is baked into stored
    /// artefact keys, so it must be stable independently of any future serde
    /// attribute change on the enum.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::Public => "public",
            Self::Restricted => "restricted",
            Self::Conformity => "conformity",
            Self::Individual => "individual",
        }
    }
}

/// Every disclosure class, in the fixed order a [`disclosure_key`] uses.
///
/// Ordering is by Annex XIII point number, and it is part of the key format:
/// two nodes must produce byte-identical keys for the same set.
pub(super) const DISCLOSURE_ORDER: &[Disclosure] = &[
    Disclosure::Public,
    Disclosure::Restricted,
    Disclosure::Conformity,
    Disclosure::Individual,
];

/// Name a set of disclosure classes: the classes' tokens in Annex XIII order,
/// joined with `+` — e.g. `public+restricted+individual`.
///
/// **This is how durable artefacts are keyed, and it must never be an audience
/// name.** ESPR uses a ~14-class actor vocabulary that is not battery's
/// three-audience lattice, and the delegated act mapping actors to data does not
/// exist yet. A signature or audit row keyed `"legitimateInterest"` would have
/// to be migrated the day that mapping lands; one keyed by the disclosure set it
/// actually covers keeps meaning exactly what it always meant, and a new actor
/// taxonomy becomes a new mapping onto the same keys.
#[must_use]
pub fn disclosure_key(classes: &[Disclosure]) -> String {
    DISCLOSURE_ORDER
        .iter()
        .filter(|c| classes.contains(c))
        .map(|c| c.token())
        .collect::<Vec<_>>()
        .join("+")
}