Skip to main content

prikk_store/
recognition_claim.rs

1//! RFC 115 Stage 2 (design-v1.md D3, §3), amended by §11 (D6) and by RFC 116 N3: what a receiver
2//! may check about a `RecognitionClaim` against its own object store.
3//!
4//! **Must not**: `block_id`/`patch_ids`/`parent_block_ids` are never existence-checked. A claim is
5//! verifiable with none of the objects it names present — that is the entire reason it is a claim
6//! object and not a Block (D3). This module never refuses on absence.
7//!
8//! **Must**: if the receiver *does* hold the referenced block, **both** the claim's `patch_ids` and
9//! its `parent_block_ids` must match that block's own, **in order** — a claim contradicting a block
10//! already held is a detected lie, about whichever field disagreed.
11//!
12//! **The comparison is exact sequence equality, not set equality (D6; extended to
13//! `parent_block_ids` by N3).** A block is content-addressed, so the same `block_id` names the same
14//! canonical payload, therefore the same `patch_ids` sequence **and** the same `parent_block_ids`
15//! sequence. An honest claim about a block the receiver genuinely holds therefore matches it in
16//! order, always, on both fields — there is no honest way to name the right block and the wrong
17//! patches, or the right block and the wrong parents. A differently-ordered (or differently-valued)
18//! claim about a held block cannot arise from honesty; only from a lie or from a lossy claim
19//! format. So sequence equality cannot produce a false accusation; it can only detect one — which
20//! set equality, used before D6, structurally could not do, since order is exactly the information
21//! a set discards. Neither side is sorted or deduplicated before comparing: `Block.patch_ids` and
22//! `Block.parent_block_ids` are the free sequences the block itself carries, and
23//! `RecognitionClaimPayload`'s own two fields mirror them verbatim by construction (the payload's
24//! own decoder/encoder no longer accept anything else).
25//!
26//! **`Contradicted` names which field disagreed (N3 §4).** A parent mismatch reported through
27//! `patch_ids`-shaped output would read as "your patches disagree" when the patches are fine — a
28//! misleading diagnostic of exactly the class RFC 115 Stage 4's divergence-vs-corruption ruling
29//! exists to prevent. A wrong explanation is worse than a vague one: it sends the reader somewhere
30//! false.
31//!
32//! **[`order_claims_for_sealing`] (RFC 116 stage 5) is `parent_block_ids`'s actual purpose.** N3
33//! added the field so a batch of claims spanning more than one block could be sealed in the right
34//! order, derived from **signed** data rather than an artifact's own unsigned sequence or an
35//! incidental id order. This is the first code that sorts by it.
36
37use std::collections::{BTreeMap, VecDeque};
38
39use prikk_error::{PrikkError, Result};
40use prikk_object::{
41    BlockPayload, ObjectEnvelope, ObjectId, ObjectType, RecognitionClaimPayload, Signature,
42    SignatureAlgorithm, SignerRole,
43};
44
45use crate::layout::RepositoryLayout;
46use crate::object_store::ObjectReader;
47use crate::trust::{MaintainerTrustPolicy, load_maintainer_trust_policy};
48use crate::trust_index::read_current_trust_policy_snapshot;
49
50/// Which field of a `RecognitionClaimPayload` a `RecognitionClaimConsistency::Contradicted`
51/// outcome names as the one that disagreed with the held block (N3 §4) -- lets a caller
52/// distinguish "the claim lies about which patches" from "the claim lies about which parents"
53/// without parsing a string.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ContradictedField {
56    /// The claim's `patch_ids` disagree with the held block's own.
57    PatchIds,
58    /// The claim's `parent_block_ids` disagree with the held block's own.
59    ParentBlockIds,
60}
61
62/// The outcome of checking a `RecognitionClaim` against the receiver's own store. Three states,
63/// not a `bool` and not a `Result<()>` that would flatten "absent" into "fine" — `BlockAbsent` is
64/// the expected case in real exchange and must not read as a degraded one.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum RecognitionClaimConsistency {
67    /// The referenced block is held, and both the claim's `patch_ids` and `parent_block_ids`
68    /// match the block's own, in order.
69    Consistent,
70    /// The referenced block is not held. Expected, not a defect — the claim is still meaningful.
71    BlockAbsent,
72    /// The referenced block is held, and one of the claim's fields does **not** match the block's
73    /// own, in order — a detected lie. `field` names which one; `patch_ids` is checked first, so a
74    /// claim disagreeing on both fields is reported as a `PatchIds` contradiction.
75    Contradicted {
76        /// Which field disagreed.
77        field: ContradictedField,
78        /// That field's own value as the claim states it, verbatim.
79        claimed: Vec<ObjectId>,
80        /// That field's own value as the held block actually has it, verbatim.
81        actual: Vec<ObjectId>,
82    },
83}
84
85/// Check `claim` against `object_store`. See the module doc for why sequence equality, not set
86/// equality, is the correct comparison under D6/N3's verbatim-order contract, and for why
87/// `Contradicted` names the disagreeing field.
88pub fn check_recognition_claim_consistency(
89    object_store: &impl ObjectReader,
90    claim: &RecognitionClaimPayload,
91) -> Result<RecognitionClaimConsistency> {
92    let Some(block_envelope) = object_store.read_typed(claim.block_id, ObjectType::Block)? else {
93        return Ok(RecognitionClaimConsistency::BlockAbsent);
94    };
95    let block_payload = BlockPayload::decode_canonical(&block_envelope.canonical_payload)?;
96    if block_payload.patch_ids != claim.patch_ids {
97        return Ok(RecognitionClaimConsistency::Contradicted {
98            field: ContradictedField::PatchIds,
99            claimed: claim.patch_ids.clone(),
100            actual: block_payload.patch_ids,
101        });
102    }
103    if block_payload.parent_block_ids != claim.parent_block_ids {
104        return Ok(RecognitionClaimConsistency::Contradicted {
105            field: ContradictedField::ParentBlockIds,
106            claimed: claim.parent_block_ids.clone(),
107            actual: block_payload.parent_block_ids,
108        });
109    }
110    Ok(RecognitionClaimConsistency::Consistent)
111}
112
113/// The outcome of checking one `RecognitionClaim`'s own MAINTAINER signature (Stage 3 handoff §4.2
114/// item 8; reused by Stage 4 §3's "report the outcome alongside the result"). Shaped identically to
115/// `AuthorSignatureVerification` and for the same reason: **never gating** (design D3) means a
116/// claim naming a `key_id` this repository has not adopted still accepts -- it reads
117/// `Unverifiable`, never `Sound`, and does not by itself refuse. Only a signature that fails to
118/// verify against a `key_id` this repository *has* adopted refuses (a forged claim under a
119/// locally-trusted identity is an integrity failure, not a trust question). There is no `Fails`
120/// variant for the same reason `AuthorSignatureVerification` has none: that outcome is a genuine
121/// refusal, propagated as an `Err`, not a value this type carries.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ClaimSignatureVerification {
124    /// The signature verifies against a `key_id` this repository has adopted as a trusted
125    /// maintainer.
126    Sound {
127        /// The MAINTAINER key id the signature named and verified against.
128        key_id: String,
129    },
130    /// This repository has not adopted `key_id`, so the signature cannot be checked. Not a
131    /// failure and not by itself a refusal -- see the type doc.
132    Unverifiable {
133        /// The MAINTAINER key id named, which this repository has not adopted.
134        key_id: String,
135    },
136}
137
138/// Verify `envelope`'s own MAINTAINER signature against `trust_policy`. The single definition of
139/// *how* a claim's signature is checked -- Stage 3's accept path and Stage 4's seal-from-accepted
140/// path both call this rather than each carrying their own copy, the same reason
141/// `verify_author_signature_against_material` was extracted for AUTHOR signatures. Refuses
142/// (`Err`) if `envelope` carries no MAINTAINER signature at all, if the signature's algorithm is
143/// not Ed25519, or if it fails to verify against an *adopted* key; reads `Unverifiable` rather
144/// than refusing when `key_id` is simply not adopted -- see `ClaimSignatureVerification`'s own doc.
145pub(crate) fn verify_claim_signature(
146    envelope: &ObjectEnvelope,
147    trust_policy: &MaintainerTrustPolicy,
148) -> Result<ClaimSignatureVerification> {
149    let claim_id = envelope.object_id();
150    let Some(signature) = envelope
151        .signatures
152        .iter()
153        .find(|signature| signature.signer_role == SignerRole::Maintainer)
154    else {
155        return Err(PrikkError::Integrity(format!(
156            "recognition claim {claim_id} carries no MAINTAINER signature -- a claim is, by \
157             definition, signed by the sender's maintainer key"
158        )));
159    };
160    if signature.algorithm != SignatureAlgorithm::Ed25519 {
161        return Err(PrikkError::InvalidSignature(format!(
162            "recognition claim {claim_id} MAINTAINER signature is not Ed25519"
163        )));
164    }
165    match trust_policy
166        .keys
167        .iter()
168        .find(|adopted| adopted.key_id == signature.key_id)
169    {
170        None => Ok(ClaimSignatureVerification::Unverifiable {
171            key_id: signature.key_id.clone(),
172        }),
173        Some(adopted) => {
174            let preimage = Signature::signed_bytes(
175                SignatureAlgorithm::Ed25519,
176                envelope.object_type,
177                claim_id,
178                SignerRole::Maintainer,
179                &signature.key_id,
180            )?;
181            if prikk_crypto::verify_ed25519(
182                &adopted.public_key,
183                &preimage,
184                &signature.signature_bytes,
185            )
186            .is_err()
187            {
188                return Err(PrikkError::InvalidSignature(format!(
189                    "recognition claim {claim_id} MAINTAINER signature does not verify against \
190                     adopted key {}",
191                    signature.key_id
192                )));
193            }
194            Ok(ClaimSignatureVerification::Sound {
195                key_id: signature.key_id.clone(),
196            })
197        }
198    }
199}
200
201/// `load_maintainer_trust_policy` deliberately errors when no policy snapshot has ever been
202/// appended -- correct for *publication* trust (`trust.rs`'s own module doc: a repository with no
203/// adopted maintainer is a trust failure for every publication), because a Block/RefState needs a
204/// definitively trusted signer to be considered sealed at all. A `RecognitionClaim`'s own signature
205/// check has no such requirement: design D3 rules a claim **never gates** on trust, so a repository
206/// that has never adopted anyone must read every claim's signer as simply not adopted -- the same
207/// outcome as an adopted-but-empty policy would produce -- not refuse over a question claim
208/// verification was never supposed to ask. A genuinely damaged policy or key-material container
209/// still propagates its error unchanged; only the "nothing has ever been adopted" case is treated
210/// as empty here.
211pub(crate) fn maintainer_trust_policy_or_empty(
212    layout: &RepositoryLayout,
213) -> Result<MaintainerTrustPolicy> {
214    match read_current_trust_policy_snapshot(layout)? {
215        Some(_) => load_maintainer_trust_policy(layout),
216        None => Ok(MaintainerTrustPolicy { keys: Vec::new() }),
217    }
218}
219
220/// Order a batch of recognition claims for sealing (RFC 116 stage 5, N3's field finally used):
221/// **a claim's block is sealed after every claim in the same batch whose block is one of its
222/// parents.** Kahn's algorithm over a graph built from each claim's own `block_id`/
223/// `parent_block_ids` -- the same shape `merge_evidence.rs`'s own `topological_order` already
224/// uses for a different node type, restated here because that function is `pub(crate)` to a
225/// different graph (Block ids already held locally) and this one's nodes are claim ids decoded
226/// from a batch that may include blocks this repository does not hold at all.
227///
228/// **Only intra-batch edges matter (§1.1).** A claim's `parent_block_ids` may name a block that is
229/// not any claim in this batch's own `block_id` -- an already-sealed ancestor, or simply absent.
230/// Such a parent is ignored for ordering, never refused on: refusing would break the ordinary
231/// incremental case, where the true parent was sealed by a previous sync and is not part of this
232/// batch at all.
233///
234/// **Deterministic (§1.1): ties are broken by the claim id itself**, the same node identity this
235/// function returns an order over -- independent chains within one batch can interleave in more
236/// than one valid order, and without a fixed tie-break two runs over the identical batch could
237/// disagree, which would be untestable and would make two receivers diverge for no reason.
238///
239/// **A cycle is a refusal, and a security property, not a tidiness one (§1.1).** Blocks are
240/// content-addressed and genuinely form a DAG, so an honest batch cannot contain one -- but a claim
241/// is an assertion, not a fact, and a hostile sender can assert a cycle a receiver holding neither
242/// block has no way to disprove on its own. The sort terminates (it is bounded by the batch size)
243/// and refuses, naming every claim still unordered when the queue empties -- never loops, never
244/// silently drops an edge to force progress.
245pub fn order_claims_for_sealing(
246    object_store: &impl ObjectReader,
247    claim_ids: &[ObjectId],
248) -> Result<Vec<ObjectId>> {
249    let mut claims: BTreeMap<ObjectId, RecognitionClaimPayload> = BTreeMap::new();
250    for &claim_id in claim_ids {
251        let envelope = object_store
252            .read_typed(claim_id, ObjectType::RecognitionClaim)?
253            .ok_or_else(|| {
254                PrikkError::Integrity(format!("recognition claim {claim_id} does not exist"))
255            })?;
256        let payload = RecognitionClaimPayload::decode_canonical(&envelope.canonical_payload)?;
257        claims.insert(claim_id, payload);
258    }
259
260    // Each batch block maps to exactly one claim -- two distinct claim objects naming the same
261    // block_id would have to disagree on patch_ids or parent_block_ids to be distinct objects at
262    // all (block_id alone does not determine a claim's identity), which makes at least one of them
263    // a lie about that same block. Refuse rather than guess which one to believe for ordering.
264    let mut block_to_claim: BTreeMap<ObjectId, ObjectId> = BTreeMap::new();
265    for (&claim_id, payload) in &claims {
266        if let Some(&existing) = block_to_claim.get(&payload.block_id) {
267            return Err(PrikkError::Integrity(format!(
268                "claims {existing} and {claim_id} both name block {} -- refusing to order an \
269                 ambiguous batch",
270                payload.block_id
271            )));
272        }
273        block_to_claim.insert(payload.block_id, claim_id);
274    }
275
276    let mut remaining_parents: BTreeMap<ObjectId, usize> = BTreeMap::new();
277    let mut children: BTreeMap<ObjectId, Vec<ObjectId>> = BTreeMap::new();
278    for (&claim_id, payload) in &claims {
279        let count = payload
280            .parent_block_ids
281            .iter()
282            .filter(|parent_block_id| block_to_claim.contains_key(*parent_block_id))
283            .count();
284        remaining_parents.insert(claim_id, count);
285        for parent_block_id in &payload.parent_block_ids {
286            if let Some(&parent_claim_id) = block_to_claim.get(parent_block_id) {
287                children.entry(parent_claim_id).or_default().push(claim_id);
288            }
289        }
290    }
291
292    // `remaining_parents`/`children` were both built by iterating `claims`, a `BTreeMap`, so every
293    // per-parent `children` entry already accumulates in ascending claim-id order -- the initial
294    // `ready` set below is sorted the same way `merge_evidence.rs`'s own precedent is, defensively,
295    // even though a `BTreeMap` iterator already yields it in that order.
296    let mut ready: Vec<ObjectId> = remaining_parents
297        .iter()
298        .filter(|(_, count)| **count == 0)
299        .map(|(id, _)| *id)
300        .collect();
301    ready.sort_unstable();
302    let mut queue: VecDeque<ObjectId> = ready.into();
303    let mut order = Vec::with_capacity(claims.len());
304    while let Some(claim_id) = queue.pop_front() {
305        order.push(claim_id);
306        for &child in children.get(&claim_id).into_iter().flatten() {
307            let entry = remaining_parents.get_mut(&child).ok_or_else(|| {
308                PrikkError::Integrity(
309                    "claim ordering lost a tracked child -- internal inconsistency".to_string(),
310                )
311            })?;
312            *entry -= 1;
313            if *entry == 0 {
314                queue.push_back(child);
315            }
316        }
317    }
318
319    if order.len() != claims.len() {
320        let stuck: Vec<String> = remaining_parents
321            .keys()
322            .filter(|id| !order.contains(id))
323            .map(ObjectId::to_string)
324            .collect();
325        return Err(PrikkError::Integrity(format!(
326            "recognition claims for sealing contain a cycle -- refusing to order: {}",
327            stuck.join(", ")
328        )));
329    }
330    Ok(order)
331}
332
333#[cfg(test)]
334mod tests;