Skip to main content

continuity_receipt/
verify.rs

1//! Bundle verification (0.1 + 0.2).
2//!
3//! Port of `continuity_receipt/verify.py`. Verdict precedence is
4//! `errors -> UNTRUSTED`, `insufficient -> INSUFFICIENT_EVIDENCE`,
5//! `provisional -> PROVISIONAL`, else `TRUSTED`. Error codes are identical to
6//! the Python verifier. Docstrings note the few places where malformed input
7//! that would raise in Python (floats in canonicalization, unhashable
8//! members, non-object shapes) is failed closed with a structured error
9//! instead.
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use serde_json::{Map, Value};
14
15use crate::canon::{canonical_bytes, commit_field, sha256_prefixed, CanonError};
16use crate::didkey;
17
18pub const SUPPORTED_SPECS: [&str; 3] = [
19    "continuity-receipt/0.1",
20    "continuity-receipt/0.2",
21    "continuity-receipt/0.3",
22];
23pub const RECORD_TYPES: [&str; 9] = [
24    "session.pass.created",
25    "task.decision",
26    "task.execution",
27    "delivery.attestation",
28    "task.termination",
29    "settlement",
30    "authority.succession",
31    "agreement.offer",
32    "agreement.accept",
33];
34
35const ANCHOR_TYPES: [&str; 3] = ["opentimestamps", "public-chain", "custom"];
36const PROVENANCE_PREFIXES: [&str; 2] = ["sha256:", "merkle-sha256:"];
37
38const PASS_FIELDS: [&str; 7] = [
39    "gate_id",
40    "mandala_class",
41    "quotas",
42    "expires_at",
43    "policy_version",
44    "mandate_ref",
45    "agent_id",
46];
47const DECISION_FIELDS: [&str; 6] = [
48    "action",
49    "action_args_hash",
50    "model",
51    "input_provenance",
52    "decision",
53    "policy_version",
54];
55const EXECUTION_FIELDS: [&str; 4] = ["tool_calls", "egress", "resources", "sandbox_class"];
56const DELIVERY_FIELDS: [&str; 3] = ["request_hash", "response_hash", "counterparty"];
57const TERMINATION_FIELDS: [&str; 3] = ["reason", "limits_at_stop", "remaining"];
58const SETTLEMENT_FIELDS: [&str; 5] = [
59    "rail",
60    "rail_ref",
61    "amount",
62    "gated_on_delivery",
63    "settled_at",
64];
65const SUCCESSION_FIELDS: [&str; 4] = ["from_authority", "to_authority", "effective_at", "reason"];
66const OFFER_FIELDS: [&str; 5] = ["offer_id", "offeree", "terms_hash", "valid_until", "nonce"];
67const ACCEPT_FIELDS: [&str; 3] = ["offer_ref", "offer_id", "terms_hash"];
68
69fn required_fields(record_type: &str) -> Option<&'static [&'static str]> {
70    match record_type {
71        "session.pass.created" => Some(&PASS_FIELDS),
72        "task.decision" => Some(&DECISION_FIELDS),
73        "task.execution" => Some(&EXECUTION_FIELDS),
74        "delivery.attestation" => Some(&DELIVERY_FIELDS),
75        "task.termination" => Some(&TERMINATION_FIELDS),
76        "settlement" => Some(&SETTLEMENT_FIELDS),
77        "authority.succession" => Some(&SUCCESSION_FIELDS),
78        "agreement.offer" => Some(&OFFER_FIELDS),
79        "agreement.accept" => Some(&ACCEPT_FIELDS),
80        _ => None,
81    }
82}
83
84/// One structured verification failure.
85#[derive(Debug, Clone)]
86pub struct ErrorEntry {
87    pub code: String,
88    pub detail: String,
89    pub receipt_id: Value,
90}
91
92/// Mirrors Python's `VerifyResult` (`as_dict` produces the same JSON shape).
93#[derive(Debug, Clone, Default)]
94pub struct VerifyResult {
95    pub errors: Vec<ErrorEntry>,
96    pub provisional_reasons: Vec<String>,
97    pub insufficient_reasons: Vec<String>,
98    pub summary: Map<String, Value>,
99}
100
101impl VerifyResult {
102    /// CTQ-aligned verdict, computed from the collected reasons/errors.
103    pub fn verdict(&self) -> &'static str {
104        if !self.errors.is_empty() {
105            "UNTRUSTED"
106        } else if !self.insufficient_reasons.is_empty() {
107            "INSUFFICIENT_EVIDENCE"
108        } else if !self.provisional_reasons.is_empty() {
109            "PROVISIONAL"
110        } else {
111            "TRUSTED"
112        }
113    }
114
115    pub fn codes(&self) -> Vec<&str> {
116        self.errors
117            .iter()
118            .map(|entry| entry.code.as_str())
119            .collect()
120    }
121
122    /// `VerifyResult.as_dict()` equivalent.
123    pub fn as_dict(&self) -> Value {
124        let mut map = Map::new();
125        map.insert(
126            "verdict".to_string(),
127            Value::String(self.verdict().to_string()),
128        );
129        map.insert("errors".to_string(), self.errors_value());
130        map.insert(
131            "provisional_reasons".to_string(),
132            Value::Array(
133                self.provisional_reasons
134                    .iter()
135                    .map(|reason| Value::String(reason.clone()))
136                    .collect(),
137            ),
138        );
139        map.insert(
140            "insufficient_reasons".to_string(),
141            Value::Array(
142                self.insufficient_reasons
143                    .iter()
144                    .map(|reason| Value::String(reason.clone()))
145                    .collect(),
146            ),
147        );
148        map.insert("summary".to_string(), Value::Object(self.summary.clone()));
149        Value::Object(map)
150    }
151
152    fn errors_value(&self) -> Value {
153        Value::Array(
154            self.errors
155                .iter()
156                .map(|entry| {
157                    let mut item = Map::new();
158                    item.insert("code".to_string(), Value::String(entry.code.clone()));
159                    item.insert("detail".to_string(), Value::String(entry.detail.clone()));
160                    item.insert("receipt_id".to_string(), entry.receipt_id.clone());
161                    Value::Object(item)
162                })
163                .collect(),
164        )
165    }
166
167    /// Result carrying a single `malformed` error (used for unparseable input).
168    pub fn malformed(detail: impl Into<String>) -> Self {
169        let mut result = Self::default();
170        fatal(&mut result, "malformed", detail.into(), None);
171        result
172    }
173}
174
175fn fatal(
176    result: &mut VerifyResult,
177    code: &str,
178    detail: impl Into<String>,
179    receipt_id: Option<&Value>,
180) {
181    result.errors.push(ErrorEntry {
182        code: code.to_string(),
183        detail: detail.into(),
184        receipt_id: receipt_id.cloned().unwrap_or(Value::Null),
185    });
186}
187
188fn spec_supported(value: Option<&Value>) -> bool {
189    value
190        .and_then(Value::as_str)
191        .map(|spec| SUPPORTED_SPECS.contains(&spec))
192        .unwrap_or(false)
193}
194
195/// Verify a parsed bundle (Python `verify_bundle`).
196pub fn verify_bundle(bundle: &Value, require_anchor: bool) -> VerifyResult {
197    let mut result = VerifyResult::default();
198
199    let Some(bundle_object) = bundle.as_object() else {
200        fatal(&mut result, "malformed", "bundle is not an object", None);
201        return result;
202    };
203
204    if !spec_supported(bundle_object.get("spec")) {
205        fatal(
206            &mut result,
207            "version_unsupported",
208            format!("spec={}", py_repr(bundle_object.get("spec"))),
209            None,
210        );
211        return result;
212    }
213
214    let Some(receipts) = bundle_object
215        .get("receipts")
216        .and_then(Value::as_array)
217        .filter(|items| !items.is_empty())
218    else {
219        fatal(&mut result, "malformed", "bundle has no receipts", None);
220        return result;
221    };
222
223    // Missing and explicit-null compare equal here, as Python's dict.get does.
224    let task_id = bundle_object
225        .get("task_id")
226        .filter(|value| !value.is_null());
227    let mut expected_prev: Option<String> = None;
228    let mut type_by_seq: BTreeMap<usize, String> = BTreeMap::new();
229
230    for (index, receipt) in receipts.iter().enumerate() {
231        let Some(record) = receipt.as_object() else {
232            fatal(
233                &mut result,
234                "malformed",
235                format!("receipt {index} is not an object"),
236                None,
237            );
238            continue;
239        };
240        let receipt_id = record.get("receipt_id");
241
242        if !spec_supported(record.get("spec")) {
243            fatal(
244                &mut result,
245                "version_unsupported",
246                format!("receipt spec={}", py_repr(record.get("spec"))),
247                receipt_id,
248            );
249        }
250        if record.get("task_id").filter(|value| !value.is_null()) != task_id {
251            fatal(
252                &mut result,
253                "task_mismatch",
254                "receipt task_id != bundle task_id",
255                receipt_id,
256            );
257        }
258
259        let record_type = record.get("type").and_then(Value::as_str);
260        let Some(record_type) = record_type.filter(|name| RECORD_TYPES.contains(name)) else {
261            fatal(
262                &mut result,
263                "unknown_type",
264                format!("type={}", py_repr(record.get("type"))),
265                receipt_id,
266            );
267            continue;
268        };
269        type_by_seq.insert(index, record_type.to_string());
270
271        let Some(body) = record.get("body").and_then(Value::as_object) else {
272            fatal(
273                &mut result,
274                "malformed",
275                "body is not an object",
276                receipt_id,
277            );
278            continue;
279        };
280        let missing: Vec<&str> = required_fields(record_type)
281            .unwrap_or(&[])
282            .iter()
283            .copied()
284            .filter(|name| !body.contains_key(*name))
285            .collect();
286        if !missing.is_empty() {
287            let listed = missing
288                .iter()
289                .map(|name| quote_py_string(name))
290                .collect::<Vec<_>>()
291                .join(", ");
292            fatal(
293                &mut result,
294                "malformed",
295                format!("missing body fields [{listed}]"),
296                receipt_id,
297            );
298        }
299
300        if !validate_timestamp(record.get("issued_at")) {
301            fatal(
302                &mut result,
303                "malformed",
304                format!(
305                    "issued_at not RFC 3339 UTC: {}",
306                    py_repr(record.get("issued_at"))
307                ),
308                receipt_id,
309            );
310        }
311
312        if !seq_matches(record.get("seq"), index) {
313            fatal(
314                &mut result,
315                "chain_break",
316                format!("seq {} != position {index}", py_str(record.get("seq"))),
317                receipt_id,
318            );
319        }
320        let prev = record.get("prev").filter(|value| !value.is_null());
321        let prev_matches = match (prev, expected_prev.as_deref()) {
322            (None, None) => true,
323            (Some(Value::String(text)), Some(expected)) => text == expected,
324            _ => false,
325        };
326        if !prev_matches {
327            fatal(
328                &mut result,
329                "chain_break",
330                "prev digest mismatch",
331                receipt_id,
332            );
333        }
334
335        match receipt_digest(record) {
336            Ok(digest) => expected_prev = Some(digest),
337            Err(error) => {
338                fatal(
339                    &mut result,
340                    "malformed",
341                    format!("canonicalization failed: {error}"),
342                    receipt_id,
343                );
344                expected_prev = None;
345                continue;
346            }
347        }
348
349        let sig = record.get("sig").and_then(Value::as_object);
350        let sig_shape_ok = sig
351            .and_then(|value| value.get("alg"))
352            .and_then(Value::as_str)
353            == Some("ed25519")
354            && py_truthy(sig.and_then(|value| value.get("value")));
355        if !sig_shape_ok {
356            fatal(
357                &mut result,
358                "bad_signature",
359                "missing or unsupported sig",
360                receipt_id,
361            );
362            continue;
363        }
364        let issuer = record
365            .get("issuer")
366            .and_then(Value::as_object)
367            .and_then(|issuer| issuer.get("id"))
368            .and_then(Value::as_str)
369            .unwrap_or("");
370        let signature = sig
371            .and_then(|value| value.get("value"))
372            .and_then(Value::as_str);
373        match canonical_bytes(&Value::Object(unsigned_view(record))) {
374            Ok(message) => {
375                let verified = signature
376                    .map(|value| didkey::verify(issuer, &message, value))
377                    .unwrap_or(false);
378                if !verified {
379                    fatal(
380                        &mut result,
381                        "bad_signature",
382                        "signature does not verify",
383                        receipt_id,
384                    );
385                }
386            }
387            Err(error) => fatal(
388                &mut result,
389                "malformed",
390                format!("canonicalization failed: {error}"),
391                receipt_id,
392            ),
393        }
394    }
395
396    check_cross_record(&mut result, receipts, &type_by_seq);
397    check_agreements(&mut result, receipts);
398    check_redactions(
399        &mut result,
400        receipts,
401        bundle_object
402            .get("disclosure_map")
403            .and_then(Value::as_object),
404    );
405    check_attestations(&mut result, receipts);
406    check_provenance(&mut result, receipts);
407    check_revocations(&mut result, bundle_object, receipts);
408    check_anchors(&mut result, bundle_object, receipts, require_anchor);
409
410    let summary = build_summary(receipts, &type_by_seq);
411    let extras = std::mem::take(&mut result.summary);
412    result.summary = summary;
413    for (key, value) in extras {
414        result.summary.insert(key, value);
415    }
416    result
417}
418
419fn check_cross_record(
420    result: &mut VerifyResult,
421    receipts: &[Value],
422    type_by_seq: &BTreeMap<usize, String>,
423) {
424    let pass_receipts: Vec<&Map<String, Value>> = receipts
425        .iter()
426        .filter_map(Value::as_object)
427        .filter(|record| record.get("type").and_then(Value::as_str) == Some("session.pass.created"))
428        .collect();
429    if pass_receipts.is_empty() {
430        fatal(
431            result,
432            "malformed",
433            "chain has no session.pass.created receipt",
434            None,
435        );
436        return;
437    }
438    let pass_body = pass_receipts[0].get("body").and_then(Value::as_object);
439    let policy_version = pass_body.and_then(|body| body.get("policy_version"));
440
441    for receipt in receipts.iter().filter_map(Value::as_object) {
442        if receipt.get("type").and_then(Value::as_str) != Some("task.decision") {
443            continue;
444        }
445        let decision_policy = receipt
446            .get("body")
447            .and_then(Value::as_object)
448            .and_then(|body| body.get("policy_version"));
449        if decision_policy != policy_version {
450            fatal(
451                result,
452                "policy_mismatch",
453                format!(
454                    "decision policy {} != pass policy {}",
455                    py_repr(decision_policy),
456                    py_repr(policy_version)
457                ),
458                receipt.get("receipt_id"),
459            );
460        }
461    }
462
463    let spend_cap = pass_body
464        .and_then(|body| body.get("spend_cap"))
465        .filter(|value| !value.is_null());
466    let settlement_indexes: Vec<usize> = type_by_seq
467        .iter()
468        .filter(|(_, record_type)| record_type.as_str() == "settlement")
469        .map(|(index, _)| *index)
470        .collect();
471    let delivery_indexes: Vec<usize> = type_by_seq
472        .iter()
473        .filter(|(_, record_type)| record_type.as_str() == "delivery.attestation")
474        .map(|(index, _)| *index)
475        .collect();
476
477    for index in settlement_indexes {
478        let Some(settlement) = receipts.get(index).and_then(Value::as_object) else {
479            continue;
480        };
481        let body = settlement.get("body").and_then(Value::as_object);
482        let amount = body.and_then(|body| body.get("amount"));
483        if let Some(cap) = spend_cap {
484            let cap_object = cap.as_object();
485            let amount_object = amount.and_then(Value::as_object);
486            let currency_mismatch = amount_object.and_then(|item| item.get("currency"))
487                != cap_object.and_then(|item| item.get("currency"));
488            let exceeded = if currency_mismatch {
489                true
490            } else {
491                match (minor_units(amount_object), minor_units(cap_object)) {
492                    (Some(settled), Some(limit)) => settled > limit,
493                    _ => true, // fail closed where Python would raise
494                }
495            };
496            if exceeded {
497                let amount_repr = amount
498                    .map(py_repr_value)
499                    .unwrap_or_else(|| "{}".to_string());
500                fatal(
501                    result,
502                    "cap_exceeded",
503                    format!(
504                        "settlement {amount_repr} exceeds cap {}",
505                        py_repr(Some(cap))
506                    ),
507                    settlement.get("receipt_id"),
508                );
509            }
510        }
511        if py_truthy(body.and_then(|body| body.get("gated_on_delivery"))) {
512            let gated_late =
513                delivery_indexes.is_empty() || delivery_indexes.iter().min().copied() > Some(index);
514            if gated_late {
515                fatal(
516                    result,
517                    "delivery_before_settlement",
518                    "gated settlement recorded before any delivery attestation",
519                    settlement.get("receipt_id"),
520                );
521            }
522        }
523    }
524
525    if !type_by_seq
526        .values()
527        .any(|record_type| record_type == "task.termination")
528    {
529        fatal(
530            result,
531            "missing_termination",
532            "task has no termination receipt",
533            None,
534        );
535    }
536}
537
538/// 0.3: `agreement.accept` binds to a preceding `agreement.offer` (spec §4.9).
539///
540/// `offer_ref` is the digest of the offer receipt; an accept whose offer is
541/// absent from the bundle is unverifiable, not false (INSUFFICIENT_EVIDENCE,
542/// `missing_offer`). A present offer with a different `offer_id`/`terms_hash`
543/// is `offer_mismatch`; an accept issued after `valid_until` is
544/// `offer_expired`.
545fn check_agreements(result: &mut VerifyResult, receipts: &[Value]) {
546    let mut offers: BTreeMap<String, &Map<String, Value>> = BTreeMap::new();
547    for receipt in receipts.iter().filter_map(Value::as_object) {
548        if receipt.get("type").and_then(Value::as_str) != Some("agreement.offer") {
549            continue;
550        }
551        if let Ok(digest) = receipt_digest(receipt) {
552            offers.insert(digest, receipt);
553        }
554    }
555
556    for receipt in receipts.iter().filter_map(Value::as_object) {
557        if receipt.get("type").and_then(Value::as_str) != Some("agreement.accept") {
558            continue;
559        }
560        let body = receipt.get("body").and_then(Value::as_object);
561        let offer = body
562            .and_then(|body| body.get("offer_ref"))
563            .and_then(Value::as_str)
564            .and_then(|reference| offers.get(reference));
565        let Some(offer) = offer else {
566            result.insufficient_reasons.push(format!(
567                "missing_offer:{}",
568                receipt
569                    .get("receipt_id")
570                    .and_then(Value::as_str)
571                    .unwrap_or("")
572            ));
573            continue;
574        };
575        let offer_body = offer.get("body").and_then(Value::as_object);
576        let valid_until = offer_body.and_then(|body| body.get("valid_until"));
577        if !validate_timestamp(valid_until) {
578            fatal(
579                result,
580                "malformed",
581                format!(
582                    "offer valid_until not RFC 3339 UTC: {}",
583                    py_repr(valid_until)
584                ),
585                offer.get("receipt_id"),
586            );
587            continue;
588        }
589        let same_offer_id = body.and_then(|body| body.get("offer_id"))
590            == offer_body.and_then(|body| body.get("offer_id"));
591        let same_terms = body.and_then(|body| body.get("terms_hash"))
592            == offer_body.and_then(|body| body.get("terms_hash"));
593        if !same_offer_id || !same_terms {
594            fatal(
595                result,
596                "offer_mismatch",
597                "accept does not match the referenced offer",
598                receipt.get("receipt_id"),
599            );
600            continue;
601        }
602        let issued_at = receipt
603            .get("issued_at")
604            .and_then(Value::as_str)
605            .and_then(parse_timestamp);
606        let valid_until = valid_until
607            .and_then(Value::as_str)
608            .and_then(parse_timestamp);
609        if let (Some(issued_at), Some(valid_until)) = (issued_at, valid_until) {
610            if issued_at > valid_until {
611                fatal(
612                    result,
613                    "offer_expired",
614                    format!(
615                        "accept issued after offer valid_until {}",
616                        py_repr(offer_body.and_then(|body| body.get("valid_until")))
617                    ),
618                    receipt.get("receipt_id"),
619                );
620            }
621        }
622    }
623}
624
625fn check_redactions(
626    result: &mut VerifyResult,
627    receipts: &[Value],
628    disclosure_map: Option<&Map<String, Value>>,
629) {
630    let root = Value::Array(receipts.to_vec());
631    let mut redactions: Vec<(String, &Map<String, Value>)> = Vec::new();
632    iter_redactions(&root, "receipts", &mut redactions);
633
634    for (path, field) in redactions {
635        if required_field_for_path(&path, receipts).is_some() {
636            fatal(
637                result,
638                "redacted_required",
639                format!("required field redacted at {path}"),
640                None,
641            );
642            continue;
643        }
644        if let Some(entry) = disclosure_map
645            .and_then(|map| map.get(path.as_str()))
646            .and_then(Value::as_object)
647        {
648            if !entry.is_empty() && entry.contains_key("salt") && entry.contains_key("value") {
649                let computed = match (
650                    entry.get("salt").and_then(Value::as_str),
651                    entry.get("value"),
652                ) {
653                    (Some(salt), Some(value)) => commit_field(salt, value).ok(),
654                    _ => None,
655                };
656                if computed.as_deref() != field.get("commit").and_then(Value::as_str) {
657                    fatal(
658                        result,
659                        "commit_mismatch",
660                        format!("commit mismatch at {path}"),
661                        None,
662                    );
663                }
664                continue;
665            }
666        }
667        if py_truthy(field.get("erased")) {
668            result
669                .insufficient_reasons
670                .push(format!("erased_content:{path}"));
671        } else {
672            result
673                .provisional_reasons
674                .push(format!("redacted_without_disclosure:{path}"));
675        }
676    }
677}
678
679/// Collect `(path, node)` for every `{"redacted": true}` node, Python order not
680/// required for verdicts (paths/reasons are identical).
681fn iter_redactions<'a>(
682    node: &'a Value,
683    path: &str,
684    out: &mut Vec<(String, &'a Map<String, Value>)>,
685) {
686    match node {
687        Value::Object(map) => {
688            if map.get("redacted") == Some(&Value::Bool(true)) {
689                out.push((path.to_string(), map));
690                return;
691            }
692            for (key, value) in map {
693                let child = if path.is_empty() {
694                    key.clone()
695                } else {
696                    format!("{path}.{key}")
697                };
698                iter_redactions(value, &child, out);
699            }
700        }
701        Value::Array(items) => {
702            for (index, value) in items.iter().enumerate() {
703                iter_redactions(value, &format!("{path}[{index}]"), out);
704            }
705        }
706        _ => {}
707    }
708}
709
710/// `_required_field_for_path`: required body fields may not be redacted.
711pub(crate) fn required_field_for_path(path: &str, receipts: &[Value]) -> Option<String> {
712    let parts: Vec<&str> = path.split('.').collect();
713    if parts.len() < 3 || !parts[0].starts_with("receipts[") || parts[1] != "body" {
714        return None;
715    }
716    let index_text = parts[0].strip_prefix("receipts[")?.trim_end_matches(']');
717    let index: usize = index_text.parse().ok()?;
718    let receipt = receipts.get(index).and_then(Value::as_object)?;
719    let record_type = receipt.get("type").and_then(Value::as_str)?;
720    required_fields(record_type)
721        .filter(|fields| fields.contains(&parts[2]))
722        .map(|_| parts[2].to_string())
723}
724
725/// 0.2: counterparty attestations are verified per-signature and reported.
726/// Absence is reported in the summary but does not change the verdict.
727fn check_attestations(result: &mut VerifyResult, receipts: &[Value]) {
728    let mut seen: Vec<Value> = Vec::new();
729    for receipt in receipts.iter().filter_map(Value::as_object) {
730        if receipt.get("type").and_then(Value::as_str) != Some("delivery.attestation") {
731            continue;
732        }
733        let body = receipt.get("body").and_then(Value::as_object);
734        let counterparty = body
735            .and_then(|body| body.get("counterparty"))
736            .and_then(Value::as_object);
737        let counterparty_id = counterparty.and_then(|item| item.get("id"));
738        let attestation = counterparty.and_then(|item| item.get("attestation"));
739
740        if !py_truthy(attestation) {
741            let mut entry = Map::new();
742            entry.insert(
743                "receipt_id".to_string(),
744                receipt.get("receipt_id").cloned().unwrap_or(Value::Null),
745            );
746            entry.insert(
747                "counterparty".to_string(),
748                counterparty_id.cloned().unwrap_or(Value::Null),
749            );
750            entry.insert(
751                "attestation".to_string(),
752                Value::String("absent".to_string()),
753            );
754            seen.push(Value::Object(entry));
755            continue;
756        }
757
758        let attestation_object = attestation.and_then(Value::as_object);
759        let algorithm_ok = attestation_object
760            .and_then(|item| item.get("alg"))
761            .and_then(Value::as_str)
762            == Some("ed25519");
763        let key = attestation_object
764            .and_then(|item| item.get("key"))
765            .and_then(Value::as_str);
766        let value = attestation_object
767            .and_then(|item| item.get("value"))
768            .and_then(Value::as_str);
769        let valid = match (body, key, value) {
770            (Some(body), Some(key), Some(value)) if algorithm_ok => {
771                let message = Value::Object(attestation_view(body));
772                canonical_bytes(&message)
773                    .map(|bytes| didkey::verify(key, &bytes, value))
774                    .unwrap_or(false)
775            }
776            _ => false,
777        };
778
779        let mut entry = Map::new();
780        entry.insert(
781            "receipt_id".to_string(),
782            receipt.get("receipt_id").cloned().unwrap_or(Value::Null),
783        );
784        entry.insert(
785            "counterparty".to_string(),
786            counterparty_id.cloned().unwrap_or(Value::Null),
787        );
788        entry.insert(
789            "attestation".to_string(),
790            Value::String(if valid { "valid" } else { "invalid" }.to_string()),
791        );
792        entry.insert(
793            "key".to_string(),
794            attestation_object
795                .and_then(|item| item.get("key"))
796                .cloned()
797                .unwrap_or(Value::Null),
798        );
799        seen.push(Value::Object(entry));
800
801        if !valid {
802            fatal(
803                result,
804                "bad_attestation",
805                "counterparty attestation does not verify",
806                receipt.get("receipt_id"),
807            );
808        }
809    }
810    if !seen.is_empty() {
811        result
812            .summary
813            .insert("attestations".to_string(), Value::Array(seen));
814    }
815}
816
817/// The view a counterparty attestation signs: body without
818/// `counterparty.attestation`.
819fn attestation_view(body: &Map<String, Value>) -> Map<String, Value> {
820    let mut view = body.clone();
821    if let Some(counterparty) = view.get("counterparty").and_then(Value::as_object).cloned() {
822        let mut counterparty = counterparty;
823        counterparty.remove("attestation");
824        view.insert("counterparty".to_string(), Value::Object(counterparty));
825    }
826    view
827}
828
829/// 0.2: `observed_sources_hash` is a flat sha256 or a merkle-sha256 root.
830fn check_provenance(result: &mut VerifyResult, receipts: &[Value]) {
831    for receipt in receipts.iter().filter_map(Value::as_object) {
832        if receipt.get("type").and_then(Value::as_str) != Some("task.decision") {
833            continue;
834        }
835        let Some(provenance) = receipt
836            .get("body")
837            .and_then(Value::as_object)
838            .and_then(|body| body.get("input_provenance"))
839            .and_then(Value::as_object)
840        else {
841            continue;
842        };
843        let Some(observed) = provenance
844            .get("observed_sources_hash")
845            .filter(|value| !value.is_null())
846        else {
847            continue;
848        };
849        let supported = observed
850            .as_str()
851            .map(|text| {
852                PROVENANCE_PREFIXES
853                    .iter()
854                    .any(|prefix| text.starts_with(*prefix))
855            })
856            .unwrap_or(false);
857        if !supported {
858            fatal(
859                result,
860                "provenance_invalid",
861                format!(
862                    "observed_sources_hash has unsupported form: {}",
863                    py_repr(Some(observed))
864                ),
865                receipt.get("receipt_id"),
866            );
867        }
868    }
869}
870
871/// 0.2: bundle-level revocation statements, self-signed by the revoked key.
872///
873/// A receipt is `UNTRUSTED` (`key_revoked`) when its issuer key was revoked at
874/// or before the receipt's `issued_at`; receipts before revocation remain
875/// valid, and invalid statements are themselves an error (fail-closed).
876fn check_revocations(result: &mut VerifyResult, bundle: &Map<String, Value>, receipts: &[Value]) {
877    let raw = bundle.get("revocations");
878    if !py_truthy(raw) {
879        return;
880    }
881    let Some(statements) = raw.and_then(Value::as_array) else {
882        fatal(result, "bad_revocation", "revocations must be a list", None);
883        return;
884    };
885
886    let mut revoked: Vec<(String, Timestamp)> = Vec::new();
887    let mut checked: usize = 0;
888    for statement in statements {
889        let Some(statement) = statement.as_object() else {
890            fatal(
891                result,
892                "bad_revocation",
893                "revocation statement is not an object",
894                None,
895            );
896            continue;
897        };
898        let key_id = statement.get("key").and_then(Value::as_str);
899        let revoked_at = statement.get("revoked_at");
900        if key_id.is_none() || !validate_timestamp(revoked_at) {
901            fatal(
902                result,
903                "bad_revocation",
904                format!(
905                    "malformed revocation statement for {}",
906                    py_repr(statement.get("key"))
907                ),
908                None,
909            );
910            continue;
911        }
912        let key_id = key_id.unwrap_or_default();
913        let revoked_at_text = revoked_at.and_then(Value::as_str).unwrap_or_default();
914
915        let sig = statement.get("sig").and_then(Value::as_object);
916        let signature_ok = sig.and_then(|item| item.get("alg")).and_then(Value::as_str)
917            == Some("ed25519")
918            && py_truthy(sig.and_then(|item| item.get("value")));
919        if !signature_ok {
920            fatal(
921                result,
922                "bad_revocation",
923                format!(
924                    "revocation statement unsigned for {}",
925                    py_repr(statement.get("key"))
926                ),
927                None,
928            );
929            continue;
930        }
931
932        let mut unsigned = statement.clone();
933        unsigned.remove("sig");
934        let verified = match canonical_bytes(&Value::Object(unsigned)) {
935            Ok(message) => sig
936                .and_then(|item| item.get("value"))
937                .and_then(Value::as_str)
938                .map(|value| didkey::verify(key_id, &message, value))
939                .unwrap_or(false),
940            Err(_) => false,
941        };
942        if !verified {
943            fatal(
944                result,
945                "bad_revocation",
946                format!(
947                    "revocation signature invalid for {}",
948                    py_repr(statement.get("key"))
949                ),
950                None,
951            );
952            continue;
953        }
954        let Some(revoked_timestamp) = parse_timestamp(revoked_at_text) else {
955            continue; // validated above; defensive
956        };
957        revoked.push((key_id.to_string(), revoked_timestamp));
958        checked += 1;
959    }
960
961    for receipt in receipts.iter().filter_map(Value::as_object) {
962        let key_id = receipt
963            .get("issuer")
964            .and_then(Value::as_object)
965            .and_then(|issuer| issuer.get("id"))
966            .and_then(Value::as_str);
967        let Some(key_id) = key_id else {
968            continue;
969        };
970        let issued_at = receipt.get("issued_at");
971        if !validate_timestamp(issued_at) {
972            continue;
973        }
974        let Some(issued_timestamp) = issued_at.and_then(Value::as_str).and_then(parse_timestamp)
975        else {
976            continue;
977        };
978        for (revoked_key, revoked_at) in &revoked {
979            if revoked_key == key_id && issued_timestamp >= *revoked_at {
980                fatal(
981                    result,
982                    "key_revoked",
983                    format!(
984                        "issuer key {key_id} was revoked at {}",
985                        isoformat(revoked_at)
986                    ),
987                    receipt.get("receipt_id"),
988                );
989            }
990        }
991    }
992    result
993        .summary
994        .insert("revocations_checked".to_string(), Value::from(checked));
995}
996
997/// Anchors: shape and digest binding only (`anchor.hash == receipt_digest`).
998fn check_anchors(
999    result: &mut VerifyResult,
1000    bundle: &Map<String, Value>,
1001    receipts: &[Value],
1002    require_anchor: bool,
1003) {
1004    let anchors = bundle.get("anchors");
1005    if !py_truthy(anchors) {
1006        if require_anchor {
1007            result
1008                .provisional_reasons
1009                .push("anchor_missing".to_string());
1010        }
1011        return;
1012    }
1013    let Some(anchor_list) = anchors.and_then(Value::as_array) else {
1014        fatal(result, "anchor_invalid", "anchors must be a list", None);
1015        result.summary.insert(
1016            "anchors".to_string(),
1017            Value::Array(vec![Value::String("hash-only".to_string())]),
1018        );
1019        return;
1020    };
1021
1022    let mut kinds: Vec<Value> = Vec::new();
1023    for anchor in anchor_list {
1024        let anchor_object = anchor.as_object();
1025        let target = anchor_object.and_then(|item| item.get("target"));
1026        let target_receipt = find_receipt_by_id(receipts, target);
1027        let bound = match (
1028            target_receipt,
1029            anchor_object
1030                .and_then(|item| item.get("hash"))
1031                .and_then(Value::as_str),
1032        ) {
1033            (Some(receipt), Some(hash)) => receipt_digest(receipt)
1034                .map(|digest| digest == hash)
1035                .unwrap_or(false),
1036            _ => false,
1037        };
1038        if !bound {
1039            fatal(
1040                result,
1041                "anchor_invalid",
1042                format!("anchor invalid for {}", py_str(target)),
1043                None,
1044            );
1045            continue;
1046        }
1047        let meta = anchor_object
1048            .and_then(|item| item.get("anchor"))
1049            .filter(|value| !value.is_null());
1050        if let Some(meta) = meta {
1051            let anchor_type = meta.as_object().and_then(|item| item.get("type"));
1052            match anchor_type
1053                .and_then(Value::as_str)
1054                .filter(|name| ANCHOR_TYPES.contains(name))
1055            {
1056                Some(name) => kinds.push(Value::String(name.to_string())),
1057                None => {
1058                    let rendered = if meta.is_object() {
1059                        py_repr(anchor_type)
1060                    } else {
1061                        py_repr(Some(meta))
1062                    };
1063                    fatal(
1064                        result,
1065                        "anchor_invalid",
1066                        format!("unknown anchor type: {rendered}"),
1067                        None,
1068                    );
1069                }
1070            }
1071        }
1072    }
1073    let summary_kinds = if kinds.is_empty() {
1074        vec![Value::String("hash-only".to_string())]
1075    } else {
1076        kinds
1077    };
1078    result
1079        .summary
1080        .insert("anchors".to_string(), Value::Array(summary_kinds));
1081}
1082
1083/// Last-receipt-wins lookup by `receipt_id`, mirroring the Python dict build.
1084fn find_receipt_by_id<'a>(
1085    receipts: &'a [Value],
1086    target: Option<&Value>,
1087) -> Option<&'a Map<String, Value>> {
1088    let target = target.filter(|value| !value.is_null());
1089    let mut found = None;
1090    for receipt in receipts.iter().filter_map(Value::as_object) {
1091        let receipt_id = receipt.get("receipt_id").filter(|value| !value.is_null());
1092        if receipt_id == target {
1093            found = Some(receipt);
1094        }
1095    }
1096    found
1097}
1098
1099fn build_summary(receipts: &[Value], type_by_seq: &BTreeMap<usize, String>) -> Map<String, Value> {
1100    let mut summary = Map::new();
1101    summary.insert("receipts".to_string(), Value::from(receipts.len()));
1102    summary.insert(
1103        "types".to_string(),
1104        Value::Array(
1105            receipts
1106                .iter()
1107                .filter_map(Value::as_object)
1108                .map(|record| record.get("type").cloned().unwrap_or(Value::Null))
1109                .collect(),
1110        ),
1111    );
1112    let mut issuers: BTreeSet<String> = BTreeSet::new();
1113    for record in receipts.iter().filter_map(Value::as_object) {
1114        let id = record
1115            .get("issuer")
1116            .and_then(Value::as_object)
1117            .and_then(|issuer| issuer.get("id"))
1118            .and_then(Value::as_str)
1119            .unwrap_or("");
1120        issuers.insert(id.to_string());
1121    }
1122    summary.insert(
1123        "issuers".to_string(),
1124        Value::Array(issuers.into_iter().map(Value::String).collect()),
1125    );
1126    summary.insert(
1127        "terminated".to_string(),
1128        Value::Bool(
1129            type_by_seq
1130                .values()
1131                .any(|record_type| record_type == "task.termination"),
1132        ),
1133    );
1134    summary.insert(
1135        "settled".to_string(),
1136        Value::Bool(
1137            type_by_seq
1138                .values()
1139                .any(|record_type| record_type == "settlement"),
1140        ),
1141    );
1142    summary
1143}
1144
1145pub(crate) fn unsigned_view(receipt: &Map<String, Value>) -> Map<String, Value> {
1146    let mut view = receipt.clone();
1147    view.remove("sig");
1148    view
1149}
1150
1151pub(crate) fn receipt_digest(receipt: &Map<String, Value>) -> Result<String, CanonError> {
1152    canonical_bytes(&Value::Object(unsigned_view(receipt))).map(|bytes| sha256_prefixed(&bytes))
1153}
1154
1155fn seq_matches(value: Option<&Value>, index: usize) -> bool {
1156    match value {
1157        Some(Value::Number(number)) => {
1158            number.as_u64() == Some(index as u64) || number.as_i64() == Some(index as i64)
1159        }
1160        // Python's bool is an int subclass (`True == 1`).
1161        Some(Value::Bool(flag)) => usize::from(*flag) == index,
1162        _ => false,
1163    }
1164}
1165
1166fn minor_units(amount: Option<&Map<String, Value>>) -> Option<i128> {
1167    match amount.and_then(|item| item.get("minor")) {
1168        None => Some(0),
1169        Some(value) => py_int(value),
1170    }
1171}
1172
1173/// Python `int()` coercion for the shapes JSON can carry.
1174fn py_int(value: &Value) -> Option<i128> {
1175    match value {
1176        Value::Bool(flag) => Some(i128::from(*flag)),
1177        Value::Number(number) => number
1178            .as_i64()
1179            .map(i128::from)
1180            .or_else(|| number.as_u64().map(i128::from))
1181            .or_else(|| number.to_string().parse::<i128>().ok()),
1182        Value::String(text) => text.trim().parse::<i128>().ok(),
1183        _ => None,
1184    }
1185}
1186
1187/// Python truthiness (`bool(value)`).
1188fn py_truthy(value: Option<&Value>) -> bool {
1189    match value {
1190        None | Some(Value::Null) => false,
1191        Some(Value::Bool(flag)) => *flag,
1192        Some(Value::Number(number)) => number.as_f64() != Some(0.0),
1193        Some(Value::String(text)) => !text.is_empty(),
1194        Some(Value::Array(items)) => !items.is_empty(),
1195        Some(Value::Object(map)) => !map.is_empty(),
1196    }
1197}
1198
1199/// Python `repr()` for the value shapes that appear in verification details.
1200fn py_repr(value: Option<&Value>) -> String {
1201    match value {
1202        None | Some(Value::Null) => "None".to_string(),
1203        Some(Value::Bool(true)) => "True".to_string(),
1204        Some(Value::Bool(false)) => "False".to_string(),
1205        Some(Value::Number(number)) => number.to_string(),
1206        Some(Value::String(text)) => quote_py_string(text),
1207        Some(Value::Array(items)) => {
1208            let parts: Vec<String> = items.iter().map(|item| py_repr(Some(item))).collect();
1209            format!("[{}]", parts.join(", "))
1210        }
1211        Some(Value::Object(map)) => {
1212            let parts: Vec<String> = map
1213                .iter()
1214                .map(|(key, item)| format!("{}: {}", quote_py_string(key), py_repr(Some(item))))
1215                .collect();
1216            format!("{{{}}}", parts.join(", "))
1217        }
1218    }
1219}
1220
1221fn py_repr_value(value: &Value) -> String {
1222    py_repr(Some(value))
1223}
1224
1225/// Python `str()` for the value shapes that appear in verification details.
1226fn py_str(value: Option<&Value>) -> String {
1227    match value {
1228        None | Some(Value::Null) => "None".to_string(),
1229        Some(Value::Bool(true)) => "True".to_string(),
1230        Some(Value::Bool(false)) => "False".to_string(),
1231        Some(Value::Number(number)) => number.to_string(),
1232        Some(Value::String(text)) => text.clone(),
1233        Some(other) => py_repr(Some(other)),
1234    }
1235}
1236
1237fn quote_py_string(text: &str) -> String {
1238    let quote = if text.contains('\'') && !text.contains('"') {
1239        '"'
1240    } else {
1241        '\''
1242    };
1243    let mut out = String::with_capacity(text.len() + 2);
1244    out.push(quote);
1245    for character in text.chars() {
1246        match character {
1247            '\\' => out.push_str("\\\\"),
1248            '\n' => out.push_str("\\n"),
1249            '\r' => out.push_str("\\r"),
1250            '\t' => out.push_str("\\t"),
1251            c if c == quote => {
1252                out.push('\\');
1253                out.push(c);
1254            }
1255            c if (c as u32) < 0x20 || c as u32 == 0x7f => {
1256                out.push_str(&format!("\\x{:02x}", c as u32));
1257            }
1258            c => out.push(c),
1259        }
1260    }
1261    out.push(quote);
1262    out
1263}
1264
1265/// RFC 3339 UTC with optional 1-3 fractional digits (`records._TIMESTAMP_RE`).
1266fn validate_timestamp(value: Option<&Value>) -> bool {
1267    value
1268        .and_then(Value::as_str)
1269        .map(is_rfc3339_utc)
1270        .unwrap_or(false)
1271}
1272
1273fn is_rfc3339_utc(text: &str) -> bool {
1274    let bytes = text.as_bytes();
1275    let is_digit = |index: usize| {
1276        bytes
1277            .get(index)
1278            .map(|byte| byte.is_ascii_digit())
1279            .unwrap_or(false)
1280    };
1281    if bytes.len() < 20 {
1282        return false;
1283    }
1284    if !(is_digit(0) && is_digit(1) && is_digit(2) && is_digit(3)) {
1285        return false;
1286    }
1287    if bytes[4] != b'-' || !(is_digit(5) && is_digit(6)) {
1288        return false;
1289    }
1290    if bytes[7] != b'-' || !(is_digit(8) && is_digit(9)) {
1291        return false;
1292    }
1293    if bytes[10] != b'T' || !(is_digit(11) && is_digit(12)) {
1294        return false;
1295    }
1296    if bytes[13] != b':' || !(is_digit(14) && is_digit(15)) {
1297        return false;
1298    }
1299    if bytes[16] != b':' || !(is_digit(17) && is_digit(18)) {
1300        return false;
1301    }
1302    if bytes.len() == 20 {
1303        return bytes[19] == b'Z';
1304    }
1305    if bytes[19] != b'.' || bytes[bytes.len() - 1] != b'Z' {
1306        return false;
1307    }
1308    let fraction = &bytes[20..bytes.len() - 1];
1309    !fraction.is_empty() && fraction.len() <= 3 && fraction.iter().all(|byte| byte.is_ascii_digit())
1310}
1311
1312/// Chronologically comparable UTC timestamp (fraction normalized to millis).
1313#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1314struct Timestamp {
1315    year: u32,
1316    month: u32,
1317    day: u32,
1318    hour: u32,
1319    minute: u32,
1320    second: u32,
1321    millis: u32,
1322}
1323
1324fn parse_timestamp(text: &str) -> Option<Timestamp> {
1325    if !is_rfc3339_utc(text) {
1326        return None;
1327    }
1328    let part = |start: usize, end: usize| -> Option<u32> { text.get(start..end)?.parse().ok() };
1329    let millis = if text.as_bytes().get(19) == Some(&b'.') {
1330        let fraction = text.get(20..text.len().checked_sub(1)?)?;
1331        let mut value = fraction.parse::<u32>().ok()?;
1332        for _ in fraction.len()..3 {
1333            value = value.checked_mul(10)?;
1334        }
1335        value
1336    } else {
1337        0
1338    };
1339    Some(Timestamp {
1340        year: part(0, 4)?,
1341        month: part(5, 7)?,
1342        day: part(8, 10)?,
1343        hour: part(11, 13)?,
1344        minute: part(14, 16)?,
1345        second: part(17, 19)?,
1346        millis,
1347    })
1348}
1349
1350/// Python `datetime.isoformat()` for a parsed UTC timestamp.
1351fn isoformat(timestamp: &Timestamp) -> String {
1352    let base = format!(
1353        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
1354        timestamp.year,
1355        timestamp.month,
1356        timestamp.day,
1357        timestamp.hour,
1358        timestamp.minute,
1359        timestamp.second
1360    );
1361    if timestamp.millis == 0 {
1362        format!("{base}+00:00")
1363    } else {
1364        format!("{base}.{:06}+00:00", timestamp.millis * 1000)
1365    }
1366}