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