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;