Skip to main content

lean_ctx/core/billing/settlement_evidence/
mod.rs

1//! Payload-free settlement-evidence eligibility for the open data plane.
2//!
3//! This module verifies a bounded, content-addressed evidence manifest. It does
4//! not approve customers, decide disputes, validate contracts, calculate a
5//! price, issue an invoice, or mutate settlement state. Those are private
6//! control-plane responsibilities. Eligibility here means only that the
7//! caller-supplied evidence set is structurally complete and internally
8//! consistent under the v2 contract.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14
15#[cfg(unix)]
16use std::os::unix::fs::OpenOptionsExt;
17
18use serde::{Deserialize, Serialize};
19
20pub const SETTLEMENT_EVIDENCE_SCHEMA_VERSION: u16 = 2;
21pub const SETTLEMENT_EVIDENCE_KIND: &str = "lean-ctx.settlement-evidence";
22pub const MAX_SETTLEMENT_EVIDENCE_ITEMS: usize = 1_000;
23pub const MAX_SETTLEMENT_TRUST_DECISIONS: usize = 1_000;
24pub const MAX_ATTRIBUTION_SOURCE_IDS: usize = 1_000;
25pub const MAX_SUPERSESSION_REFS: usize = 32;
26pub const MAX_SETTLEMENT_MANIFEST_BYTES: u64 = 4 * 1024 * 1024;
27pub const MAX_SETTLEMENT_STRING_BYTES: usize = 256;
28
29const PENDING_MANIFEST_ID: &str = "manifest:pending";
30const PENDING_EVIDENCE_ID: &str = "artifact:pending";
31const PENDING_TRUST_STORE_ID: &str = "trust-store:pending";
32static ATOMIC_EXPORT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
33
34/// Closed set of evidence roles required by settlement-evidence v2.
35#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum SettlementEvidenceRoleV2 {
38    Baseline,
39    Price,
40    Contract,
41    Quality,
42    Attribution,
43    PeriodCompletion,
44    CustomerApproval,
45}
46
47/// A trust decision is an externally produced, content-addressed input.
48///
49/// The OSS verifier checks the decision's presence and integrity-shaped IDs; it
50/// does not claim the referenced authority is legally or commercially valid.
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct EvidenceTrustV2 {
54    pub status: EvidenceTrustStatusV2,
55    pub trust_decision_id: String,
56    pub trust_anchor_id: String,
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum EvidenceTrustStatusV2 {
62    Trusted,
63    Untrusted,
64}
65
66/// One out-of-band trust decision pinned by the verifier/operator.
67#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct TrustedEvidenceDecisionV2 {
70    pub evidence_id: String,
71    pub trust_decision_id: String,
72    pub trust_anchor_id: String,
73}
74
75/// Caller-owned trust input. A manifest cannot make itself trusted by merely
76/// setting `status = trusted`; the exact tuple must also exist here.
77#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct SettlementEvidenceTrustStoreV2 {
80    pub schema_version: u16,
81    pub trust_store_id: String,
82    pub trusted_decisions: Vec<TrustedEvidenceDecisionV2>,
83}
84
85impl SettlementEvidenceTrustStoreV2 {
86    pub fn new(
87        trusted_decisions: Vec<TrustedEvidenceDecisionV2>,
88    ) -> Result<Self, SettlementEvidenceError> {
89        let mut store = Self {
90            schema_version: SETTLEMENT_EVIDENCE_SCHEMA_VERSION,
91            trust_store_id: PENDING_TRUST_STORE_ID.to_string(),
92            trusted_decisions,
93        };
94        ensure_trust_store_bounds(&store)?;
95        store.canonicalize();
96        store.trust_store_id = store.computed_trust_store_id()?;
97        Ok(store)
98    }
99
100    /// Empty trust is the safe default: no self-attested manifest can qualify.
101    #[must_use]
102    pub fn empty() -> Self {
103        Self::new(Vec::new()).expect("empty trust store is bounded")
104    }
105
106    pub fn load(path: &Path) -> Result<Self, SettlementEvidenceError> {
107        let store: Self = read_json_bounded(path)?;
108        ensure_trust_store_bounds(&store)?;
109        Ok(store)
110    }
111
112    pub fn canonical_json(&self) -> Result<String, SettlementEvidenceError> {
113        ensure_trust_store_bounds(self)?;
114        if self.trust_store_id != self.computed_trust_store_id()? {
115            return Err(SettlementEvidenceError::IntegrityMismatch);
116        }
117        let mut canonical = self.clone();
118        canonical.canonicalize();
119        serde_json::to_string(&canonical).map_err(SettlementEvidenceError::Serialize)
120    }
121
122    fn canonicalize(&mut self) {
123        self.trusted_decisions.sort();
124    }
125
126    fn computed_trust_store_id(&self) -> Result<String, SettlementEvidenceError> {
127        ensure_trust_store_bounds(self)?;
128        let mut identity = self.clone();
129        identity.trust_store_id = PENDING_TRUST_STORE_ID.to_string();
130        identity.canonicalize();
131        Ok(format!(
132            "trust-store:blake3:{}",
133            hash_bounded_json(&identity)?.to_hex()
134        ))
135    }
136
137    fn contains(&self, item: &SettlementEvidenceItemV2) -> bool {
138        self.trusted_decisions.iter().any(|trusted| {
139            trusted.evidence_id == item.evidence_id
140                && trusted.trust_decision_id == item.trust.trust_decision_id
141                && trusted.trust_anchor_id == item.trust.trust_anchor_id
142        })
143    }
144}
145
146#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum EvidenceStateV2 {
149    Active,
150    Disputed,
151    Superseded,
152}
153
154/// Evidence strength is explicit; settlement consumers never infer it from a
155/// signature, file name, or claim role.
156#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum SettlementEvidenceClassV2 {
159    Measured,
160    Reconciled,
161    Declared,
162    Derived,
163    Unknown,
164}
165
166#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct SettlementEvidenceMethodV2 {
169    pub method_artifact_id: String,
170    pub evidence_class: SettlementEvidenceClassV2,
171}
172
173/// Typed, payload-free evidence claim. Monetary quantities never use floats.
174#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
175#[serde(tag = "role", rename_all = "snake_case", deny_unknown_fields)]
176pub enum SettlementEvidenceClaimV2 {
177    Baseline {
178        baseline_version_id: String,
179        baseline_tokens: u64,
180    },
181    Price {
182        price_version_id: String,
183        currency: String,
184        unit_price_micros: u64,
185    },
186    Contract {
187        contract_version_id: String,
188    },
189    Quality {
190        quality_gate_id: String,
191        passed: bool,
192    },
193    Attribution {
194        mechanism_id: String,
195        exclusive: bool,
196        attributed_tokens: u64,
197        attributed_minor_units: u64,
198        source_evidence_ids: Vec<String>,
199    },
200    PeriodCompletion {
201        period_start_epoch_seconds: i64,
202        period_end_epoch_seconds: i64,
203        complete: bool,
204    },
205    CustomerApproval {
206        approval_artifact_id: String,
207        approved: bool,
208    },
209}
210
211impl SettlementEvidenceClaimV2 {
212    #[must_use]
213    pub const fn role(&self) -> SettlementEvidenceRoleV2 {
214        match self {
215            Self::Baseline { .. } => SettlementEvidenceRoleV2::Baseline,
216            Self::Price { .. } => SettlementEvidenceRoleV2::Price,
217            Self::Contract { .. } => SettlementEvidenceRoleV2::Contract,
218            Self::Quality { .. } => SettlementEvidenceRoleV2::Quality,
219            Self::Attribution { .. } => SettlementEvidenceRoleV2::Attribution,
220            Self::PeriodCompletion { .. } => SettlementEvidenceRoleV2::PeriodCompletion,
221            Self::CustomerApproval { .. } => SettlementEvidenceRoleV2::CustomerApproval,
222        }
223    }
224
225    fn canonicalize(&mut self) {
226        if let Self::Attribution {
227            source_evidence_ids,
228            ..
229        } = self
230        {
231            source_evidence_ids.sort();
232        }
233    }
234}
235
236/// One self-contained evidence projection. `evidence_id` commits to every
237/// other field using canonical JSON and BLAKE3.
238#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct SettlementEvidenceItemV2 {
241    pub evidence_id: String,
242    pub subject_id: String,
243    pub state: EvidenceStateV2,
244    pub trust: EvidenceTrustV2,
245    pub measurement: SettlementEvidenceMethodV2,
246    pub claim: SettlementEvidenceClaimV2,
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub supersedes: Vec<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub correction_reason_id: Option<String>,
251}
252
253impl SettlementEvidenceItemV2 {
254    pub fn new(
255        subject_id: String,
256        claim: SettlementEvidenceClaimV2,
257        trust: EvidenceTrustV2,
258    ) -> Result<Self, SettlementEvidenceError> {
259        let measurement = default_method(&claim);
260        Self::new_with_method(subject_id, claim, trust, measurement)
261    }
262
263    pub fn new_with_method(
264        subject_id: String,
265        claim: SettlementEvidenceClaimV2,
266        trust: EvidenceTrustV2,
267        measurement: SettlementEvidenceMethodV2,
268    ) -> Result<Self, SettlementEvidenceError> {
269        let mut item = Self {
270            evidence_id: PENDING_EVIDENCE_ID.to_string(),
271            subject_id,
272            state: EvidenceStateV2::Active,
273            trust,
274            measurement,
275            claim,
276            supersedes: Vec::new(),
277            correction_reason_id: None,
278        };
279        ensure_item_bounds(&item)?;
280        item.canonicalize();
281        item.evidence_id = item.computed_evidence_id()?;
282        Ok(item)
283    }
284
285    /// Create an active correction that explicitly supersedes older evidence.
286    pub fn corrected(
287        subject_id: String,
288        claim: SettlementEvidenceClaimV2,
289        trust: EvidenceTrustV2,
290        supersedes: Vec<String>,
291        correction_reason_id: String,
292    ) -> Result<Self, SettlementEvidenceError> {
293        let mut item = Self {
294            evidence_id: PENDING_EVIDENCE_ID.to_string(),
295            subject_id,
296            state: EvidenceStateV2::Active,
297            trust,
298            measurement: default_method(&claim),
299            claim,
300            supersedes,
301            correction_reason_id: Some(correction_reason_id),
302        };
303        ensure_item_bounds(&item)?;
304        item.canonicalize();
305        item.evidence_id = item.computed_evidence_id()?;
306        Ok(item)
307    }
308
309    fn canonicalize(&mut self) {
310        self.supersedes.sort();
311        self.claim.canonicalize();
312    }
313
314    fn computed_evidence_id(&self) -> Result<String, SettlementEvidenceError> {
315        ensure_item_bounds(self)?;
316        let mut identity = self.clone();
317        identity.evidence_id = PENDING_EVIDENCE_ID.to_string();
318        identity.canonicalize();
319        Ok(format!(
320            "artifact:blake3:{}",
321            hash_bounded_json(&identity)?.to_hex()
322        ))
323    }
324}
325
326#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct SettlementPeriodV2 {
329    pub start_epoch_seconds: i64,
330    pub end_epoch_seconds: i64,
331}
332
333/// Canonical payload-free export consumed across the OSS/commercial boundary.
334#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct SettlementEvidenceManifestV2 {
337    pub schema_version: u16,
338    pub kind: String,
339    pub manifest_id: String,
340    pub subject_id: String,
341    pub period: SettlementPeriodV2,
342    /// Uppercase ISO 4217 alpha code. The verifier checks shape, not legal use.
343    pub currency: String,
344    /// Caller-provided amount in the currency's minor unit; never a float.
345    pub claimed_amount_minor_units: u64,
346    pub evidence: Vec<SettlementEvidenceItemV2>,
347}
348
349impl SettlementEvidenceManifestV2 {
350    pub fn new(
351        subject_id: String,
352        period: SettlementPeriodV2,
353        currency: String,
354        claimed_amount_minor_units: u64,
355        evidence: Vec<SettlementEvidenceItemV2>,
356    ) -> Result<Self, SettlementEvidenceError> {
357        let mut manifest = Self {
358            schema_version: SETTLEMENT_EVIDENCE_SCHEMA_VERSION,
359            kind: SETTLEMENT_EVIDENCE_KIND.to_string(),
360            manifest_id: PENDING_MANIFEST_ID.to_string(),
361            subject_id,
362            period,
363            currency,
364            claimed_amount_minor_units,
365            evidence,
366        };
367        ensure_manifest_bounds(&manifest)?;
368        manifest.canonicalize();
369        manifest.manifest_id = manifest.computed_manifest_id()?;
370        Ok(manifest)
371    }
372
373    /// Canonical JSON, stable across input evidence permutations.
374    pub fn canonical_json(&self) -> Result<String, SettlementEvidenceError> {
375        ensure_manifest_bounds(self)?;
376        if self.manifest_id != self.computed_manifest_id()?
377            || self
378                .evidence
379                .iter()
380                .any(|item| address_mismatch(item.computed_evidence_id(), &item.evidence_id))
381        {
382            return Err(SettlementEvidenceError::IntegrityMismatch);
383        }
384        let mut canonical = self.clone();
385        canonical.canonicalize();
386        serde_json::to_string(&canonical).map_err(SettlementEvidenceError::Serialize)
387    }
388
389    /// Bounded offline load. Unknown JSON fields are rejected by serde.
390    pub fn load(path: &Path) -> Result<Self, SettlementEvidenceError> {
391        let manifest: Self = read_json_bounded(path)?;
392        ensure_manifest_bounds(&manifest)?;
393        Ok(manifest)
394    }
395
396    /// Export canonical JSON without changing any approval/dispute state.
397    pub fn export(&self, path: &Path) -> Result<(), SettlementEvidenceError> {
398        let body = self.canonical_json()?;
399        atomic_write_no_symlink(path, body.as_bytes())
400    }
401
402    fn canonicalize(&mut self) {
403        for item in &mut self.evidence {
404            item.canonicalize();
405        }
406        self.evidence
407            .sort_by(|a, b| a.evidence_id.cmp(&b.evidence_id));
408    }
409
410    fn computed_manifest_id(&self) -> Result<String, SettlementEvidenceError> {
411        ensure_manifest_bounds(self)?;
412        let mut identity = self.clone();
413        identity.manifest_id = PENDING_MANIFEST_ID.to_string();
414        identity.canonicalize();
415        Ok(format!(
416            "manifest:blake3:{}",
417            hash_bounded_json(&identity)?.to_hex()
418        ))
419    }
420}
421
422/// Fail-closed reasons emitted by the offline reconciler.
423#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
424#[serde(rename_all = "snake_case")]
425pub enum SettlementIneligibilityReasonV2 {
426    UnsupportedSchemaVersion,
427    InvalidKind,
428    InvalidManifestId,
429    InvalidSubjectId,
430    InvalidPeriod,
431    InvalidCurrency,
432    InvalidTrustStore,
433    TooManyEvidenceItems,
434    TooManyTrustDecisions,
435    DuplicateTrustDecision,
436    TooManyAttributionSources,
437    TooManySupersessionRefs,
438    OversizedString,
439    ManifestTooLarge,
440    DuplicateEvidenceId {
441        evidence_id: String,
442    },
443    InvalidEvidenceId {
444        evidence_id: String,
445    },
446    SubjectMismatch {
447        evidence_id: String,
448    },
449    MissingEvidence {
450        role: SettlementEvidenceRoleV2,
451    },
452    AmbiguousEvidence {
453        role: SettlementEvidenceRoleV2,
454    },
455    UntrustedEvidence {
456        evidence_id: String,
457    },
458    DisputedEvidence {
459        evidence_id: String,
460    },
461    SupersededEvidence {
462        evidence_id: String,
463    },
464    InvalidEvidenceReference {
465        evidence_id: String,
466    },
467    InvalidEvidenceMethod {
468        evidence_id: String,
469    },
470    IneligibleEvidenceClass {
471        evidence_id: String,
472    },
473    InvalidCorrectionLineage {
474        evidence_id: String,
475    },
476    CorrectionTargetCollision {
477        target_id: String,
478        correction_ids: Vec<String>,
479    },
480    IncompletePeriod,
481    QualityGateFailed,
482    CustomerApprovalNotGranted,
483    CurrencyMismatch {
484        evidence_id: String,
485    },
486    NonExclusiveAttribution {
487        evidence_id: String,
488    },
489    DuplicateAttribution {
490        source_evidence_id: String,
491    },
492    InvalidAttributedAmount {
493        evidence_id: String,
494    },
495    AttributionExceedsBaseline,
496    ClaimedAmountMismatch,
497    ArithmeticOverflow,
498}
499
500/// Deterministic reconciliation output. The three authority flags are always
501/// false because OSS verification cannot issue invoices or validate private
502/// contract/approval authority.
503#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
504#[serde(deny_unknown_fields)]
505pub struct SettlementEligibilityV2 {
506    pub schema_version: u16,
507    pub manifest_id: String,
508    pub trust_store_id: String,
509    pub eligible: bool,
510    pub reasons: Vec<SettlementIneligibilityReasonV2>,
511    pub evidence_count: usize,
512    pub active_evidence_count: usize,
513    pub attributed_tokens: Option<u64>,
514    pub attributed_minor_units: Option<u64>,
515    pub invoice_authority: bool,
516    pub contract_validity_verified: bool,
517    pub customer_approval_authority_verified: bool,
518}
519
520/// Reconcile a manifest under the v2 evidence contract.
521///
522/// Processing is bounded to 1,000 evidence items and 1,000 total attribution
523/// source IDs. Oversized input returns immediately or stops at the global cap.
524#[must_use]
525pub fn reconcile_settlement_evidence_v2(
526    manifest: &SettlementEvidenceManifestV2,
527    trust_store: &SettlementEvidenceTrustStoreV2,
528) -> SettlementEligibilityV2 {
529    let mut reasons = Vec::new();
530    if let Err(error) = ensure_manifest_bounds(manifest) {
531        reasons.push(bound_error_reason(&error, false));
532    }
533    if let Err(error) = ensure_trust_store_bounds(trust_store) {
534        reasons.push(bound_error_reason(&error, true));
535    }
536    if !reasons.is_empty() {
537        return eligibility(manifest, trust_store, reasons, 0, None, None);
538    }
539    if trust_store.schema_version != SETTLEMENT_EVIDENCE_SCHEMA_VERSION
540        || trust_store
541            .computed_trust_store_id()
542            .map_or(true, |computed| trust_store.trust_store_id != computed)
543    {
544        reasons.push(SettlementIneligibilityReasonV2::InvalidTrustStore);
545    }
546    let unique_trust_decisions: BTreeSet<_> = trust_store.trusted_decisions.iter().collect();
547    if unique_trust_decisions.len() != trust_store.trusted_decisions.len() {
548        reasons.push(SettlementIneligibilityReasonV2::DuplicateTrustDecision);
549    }
550    if trust_store.trusted_decisions.iter().any(|decision| {
551        !valid_address(&decision.evidence_id, "artifact:blake3:")
552            || !valid_address(&decision.trust_decision_id, "artifact:blake3:")
553            || !valid_address(&decision.trust_anchor_id, "anchor:blake3:")
554    }) {
555        reasons.push(SettlementIneligibilityReasonV2::InvalidTrustStore);
556    }
557
558    if manifest.schema_version != SETTLEMENT_EVIDENCE_SCHEMA_VERSION {
559        reasons.push(SettlementIneligibilityReasonV2::UnsupportedSchemaVersion);
560    }
561    if manifest.kind != SETTLEMENT_EVIDENCE_KIND {
562        reasons.push(SettlementIneligibilityReasonV2::InvalidKind);
563    }
564    if manifest
565        .computed_manifest_id()
566        .map_or(true, |computed| manifest.manifest_id != computed)
567        || !valid_address(&manifest.manifest_id, "manifest:blake3:")
568    {
569        reasons.push(SettlementIneligibilityReasonV2::InvalidManifestId);
570    }
571    if !valid_address(&manifest.subject_id, "subject:blake3:") {
572        reasons.push(SettlementIneligibilityReasonV2::InvalidSubjectId);
573    }
574    if manifest.period.start_epoch_seconds < 0
575        || manifest.period.end_epoch_seconds <= manifest.period.start_epoch_seconds
576    {
577        reasons.push(SettlementIneligibilityReasonV2::InvalidPeriod);
578    }
579    if !valid_iso_currency(&manifest.currency) {
580        reasons.push(SettlementIneligibilityReasonV2::InvalidCurrency);
581    }
582
583    let mut canonical_evidence = manifest.evidence.clone();
584    for item in &mut canonical_evidence {
585        item.canonicalize();
586    }
587    canonical_evidence.sort_by(|a, b| a.evidence_id.cmp(&b.evidence_id));
588
589    let mut seen_evidence = BTreeSet::new();
590    let mut roles: BTreeMap<SettlementEvidenceRoleV2, Vec<&SettlementEvidenceItemV2>> =
591        BTreeMap::new();
592    let mut active_count = 0;
593    let mut correction_targets: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
594
595    for item in &canonical_evidence {
596        if !seen_evidence.insert(item.evidence_id.clone()) {
597            reasons.push(SettlementIneligibilityReasonV2::DuplicateEvidenceId {
598                evidence_id: item.evidence_id.clone(),
599            });
600        }
601        if item
602            .computed_evidence_id()
603            .map_or(true, |computed| item.evidence_id != computed)
604            || !valid_address(&item.evidence_id, "artifact:blake3:")
605        {
606            reasons.push(SettlementIneligibilityReasonV2::InvalidEvidenceId {
607                evidence_id: item.evidence_id.clone(),
608            });
609        }
610        if item.subject_id != manifest.subject_id {
611            reasons.push(SettlementIneligibilityReasonV2::SubjectMismatch {
612                evidence_id: item.evidence_id.clone(),
613            });
614        }
615        if !valid_address(&item.trust.trust_decision_id, "artifact:blake3:")
616            || !valid_address(&item.trust.trust_anchor_id, "anchor:blake3:")
617        {
618            reasons.push(SettlementIneligibilityReasonV2::InvalidEvidenceReference {
619                evidence_id: item.evidence_id.clone(),
620            });
621        }
622        if !valid_address(&item.measurement.method_artifact_id, "artifact:blake3:") {
623            reasons.push(SettlementIneligibilityReasonV2::InvalidEvidenceMethod {
624                evidence_id: item.evidence_id.clone(),
625            });
626        }
627        if item.measurement.evidence_class != expected_evidence_class(item.claim.role()) {
628            reasons.push(SettlementIneligibilityReasonV2::IneligibleEvidenceClass {
629                evidence_id: item.evidence_id.clone(),
630            });
631        }
632        if item.trust.status == EvidenceTrustStatusV2::Untrusted || !trust_store.contains(item) {
633            reasons.push(SettlementIneligibilityReasonV2::UntrustedEvidence {
634                evidence_id: item.evidence_id.clone(),
635            });
636        }
637        match item.state {
638            EvidenceStateV2::Active => {
639                active_count += 1;
640                roles.entry(item.claim.role()).or_default().push(item);
641            }
642            EvidenceStateV2::Disputed => {
643                reasons.push(SettlementIneligibilityReasonV2::DisputedEvidence {
644                    evidence_id: item.evidence_id.clone(),
645                });
646            }
647            EvidenceStateV2::Superseded => {
648                reasons.push(SettlementIneligibilityReasonV2::SupersededEvidence {
649                    evidence_id: item.evidence_id.clone(),
650                });
651            }
652        }
653        validate_correction_lineage(item, &mut reasons);
654        for target in &item.supersedes {
655            correction_targets
656                .entry(target.clone())
657                .or_default()
658                .insert(item.evidence_id.clone());
659        }
660        validate_claim_references(item, manifest, &mut reasons);
661    }
662    for (target_id, correction_ids) in correction_targets {
663        if correction_ids.len() > 1 {
664            reasons.push(SettlementIneligibilityReasonV2::CorrectionTargetCollision {
665                target_id,
666                correction_ids: correction_ids.into_iter().collect(),
667            });
668        }
669    }
670
671    for role in [
672        SettlementEvidenceRoleV2::Baseline,
673        SettlementEvidenceRoleV2::Price,
674        SettlementEvidenceRoleV2::Contract,
675        SettlementEvidenceRoleV2::Quality,
676        SettlementEvidenceRoleV2::PeriodCompletion,
677        SettlementEvidenceRoleV2::CustomerApproval,
678    ] {
679        match roles.get(&role).map(Vec::len).unwrap_or_default() {
680            0 => reasons.push(SettlementIneligibilityReasonV2::MissingEvidence { role }),
681            1 => {}
682            _ => reasons.push(SettlementIneligibilityReasonV2::AmbiguousEvidence { role }),
683        }
684    }
685
686    let attributions = roles
687        .get(&SettlementEvidenceRoleV2::Attribution)
688        .cloned()
689        .unwrap_or_default();
690    if attributions.is_empty() {
691        reasons.push(SettlementIneligibilityReasonV2::MissingEvidence {
692            role: SettlementEvidenceRoleV2::Attribution,
693        });
694    }
695
696    let mut mechanisms = BTreeSet::new();
697    let mut sources = BTreeSet::new();
698    let mut attributed_tokens = Some(0_u64);
699    let mut attributed_minor_units = Some(0_u64);
700    for item in attributions {
701        if let SettlementEvidenceClaimV2::Attribution {
702            mechanism_id,
703            exclusive,
704            attributed_tokens: tokens,
705            attributed_minor_units: minor,
706            source_evidence_ids,
707        } = &item.claim
708        {
709            if !mechanisms.insert(mechanism_id.clone()) {
710                reasons.push(SettlementIneligibilityReasonV2::AmbiguousEvidence {
711                    role: SettlementEvidenceRoleV2::Attribution,
712                });
713            }
714            if !exclusive {
715                reasons.push(SettlementIneligibilityReasonV2::NonExclusiveAttribution {
716                    evidence_id: item.evidence_id.clone(),
717                });
718            }
719            if *tokens == 0 || *minor == 0 || source_evidence_ids.is_empty() {
720                reasons.push(SettlementIneligibilityReasonV2::InvalidAttributedAmount {
721                    evidence_id: item.evidence_id.clone(),
722                });
723            }
724            for source in source_evidence_ids {
725                if !sources.insert(source.clone()) {
726                    reasons.push(SettlementIneligibilityReasonV2::DuplicateAttribution {
727                        source_evidence_id: source.clone(),
728                    });
729                }
730            }
731            checked_add(&mut attributed_tokens, *tokens, &mut reasons);
732            checked_add(&mut attributed_minor_units, *minor, &mut reasons);
733        }
734    }
735
736    if let (Some(total), Some(baselines)) = (
737        attributed_tokens,
738        roles.get(&SettlementEvidenceRoleV2::Baseline),
739    ) && baselines.len() == 1
740        && let SettlementEvidenceClaimV2::Baseline {
741            baseline_tokens, ..
742        } = &baselines[0].claim
743        && total > *baseline_tokens
744    {
745        reasons.push(SettlementIneligibilityReasonV2::AttributionExceedsBaseline);
746    }
747    if attributed_minor_units.is_some_and(|total| total != manifest.claimed_amount_minor_units) {
748        reasons.push(SettlementIneligibilityReasonV2::ClaimedAmountMismatch);
749    }
750
751    eligibility(
752        manifest,
753        trust_store,
754        reasons,
755        active_count,
756        attributed_tokens,
757        attributed_minor_units,
758    )
759}
760
761fn validate_correction_lineage(
762    item: &SettlementEvidenceItemV2,
763    reasons: &mut Vec<SettlementIneligibilityReasonV2>,
764) {
765    let mut local_targets = BTreeSet::new();
766    let invalid = item.supersedes.len() > MAX_SUPERSESSION_REFS
767        || (item.supersedes.is_empty() != item.correction_reason_id.is_none())
768        || item.state != EvidenceStateV2::Active && !item.supersedes.is_empty()
769        || item.supersedes.iter().any(|target| {
770            target == &item.evidence_id
771                || !valid_address(target, "artifact:blake3:")
772                || !local_targets.insert(target)
773        })
774        || item
775            .correction_reason_id
776            .as_ref()
777            .is_some_and(|id| !valid_address(id, "artifact:blake3:"));
778    if invalid {
779        reasons.push(SettlementIneligibilityReasonV2::InvalidCorrectionLineage {
780            evidence_id: item.evidence_id.clone(),
781        });
782    }
783}
784
785fn validate_claim_references(
786    item: &SettlementEvidenceItemV2,
787    manifest: &SettlementEvidenceManifestV2,
788    reasons: &mut Vec<SettlementIneligibilityReasonV2>,
789) {
790    let invalid_reference = match &item.claim {
791        SettlementEvidenceClaimV2::Baseline {
792            baseline_version_id,
793            baseline_tokens,
794        } => !valid_address(baseline_version_id, "artifact:blake3:") || *baseline_tokens == 0,
795        SettlementEvidenceClaimV2::Price {
796            price_version_id,
797            currency,
798            unit_price_micros,
799        } => {
800            if currency != &manifest.currency {
801                reasons.push(SettlementIneligibilityReasonV2::CurrencyMismatch {
802                    evidence_id: item.evidence_id.clone(),
803                });
804            }
805            !valid_address(price_version_id, "artifact:blake3:") || *unit_price_micros == 0
806        }
807        SettlementEvidenceClaimV2::Contract {
808            contract_version_id,
809        } => !valid_address(contract_version_id, "artifact:blake3:"),
810        SettlementEvidenceClaimV2::Quality {
811            quality_gate_id,
812            passed,
813        } => {
814            if !passed {
815                reasons.push(SettlementIneligibilityReasonV2::QualityGateFailed);
816            }
817            !valid_address(quality_gate_id, "artifact:blake3:")
818        }
819        SettlementEvidenceClaimV2::Attribution {
820            mechanism_id,
821            source_evidence_ids,
822            ..
823        } => {
824            !valid_address(mechanism_id, "mechanism:blake3:")
825                || source_evidence_ids
826                    .iter()
827                    .any(|id| !valid_address(id, "artifact:blake3:"))
828        }
829        SettlementEvidenceClaimV2::PeriodCompletion {
830            period_start_epoch_seconds,
831            period_end_epoch_seconds,
832            complete,
833        } => {
834            if !complete
835                || *period_start_epoch_seconds != manifest.period.start_epoch_seconds
836                || *period_end_epoch_seconds != manifest.period.end_epoch_seconds
837            {
838                reasons.push(SettlementIneligibilityReasonV2::IncompletePeriod);
839            }
840            false
841        }
842        SettlementEvidenceClaimV2::CustomerApproval {
843            approval_artifact_id,
844            approved,
845        } => {
846            if !approved {
847                reasons.push(SettlementIneligibilityReasonV2::CustomerApprovalNotGranted);
848            }
849            !valid_address(approval_artifact_id, "artifact:blake3:")
850        }
851    };
852    if invalid_reference {
853        reasons.push(SettlementIneligibilityReasonV2::InvalidEvidenceReference {
854            evidence_id: item.evidence_id.clone(),
855        });
856    }
857}
858
859fn checked_add(
860    total: &mut Option<u64>,
861    value: u64,
862    reasons: &mut Vec<SettlementIneligibilityReasonV2>,
863) {
864    if let Some(current) = *total {
865        if let Some(next) = current.checked_add(value) {
866            *total = Some(next);
867        } else {
868            *total = None;
869            reasons.push(SettlementIneligibilityReasonV2::ArithmeticOverflow);
870        }
871    }
872}
873
874fn default_method(claim: &SettlementEvidenceClaimV2) -> SettlementEvidenceMethodV2 {
875    let role = claim.role();
876    let label = match role {
877        SettlementEvidenceRoleV2::Baseline => "settlement-baseline-method-v2",
878        SettlementEvidenceRoleV2::Price => "settlement-price-declaration-v2",
879        SettlementEvidenceRoleV2::Contract => "settlement-contract-declaration-v2",
880        SettlementEvidenceRoleV2::Quality => "settlement-quality-reconciliation-v2",
881        SettlementEvidenceRoleV2::Attribution => "settlement-exclusive-attribution-v2",
882        SettlementEvidenceRoleV2::PeriodCompletion => "settlement-period-observation-v2",
883        SettlementEvidenceRoleV2::CustomerApproval => "settlement-approval-declaration-v2",
884    };
885    SettlementEvidenceMethodV2 {
886        method_artifact_id: format!(
887            "artifact:blake3:{}",
888            blake3::hash(label.as_bytes()).to_hex()
889        ),
890        evidence_class: expected_evidence_class(role),
891    }
892}
893
894const fn expected_evidence_class(role: SettlementEvidenceRoleV2) -> SettlementEvidenceClassV2 {
895    match role {
896        SettlementEvidenceRoleV2::Baseline | SettlementEvidenceRoleV2::PeriodCompletion => {
897            SettlementEvidenceClassV2::Measured
898        }
899        SettlementEvidenceRoleV2::Quality | SettlementEvidenceRoleV2::Attribution => {
900            SettlementEvidenceClassV2::Reconciled
901        }
902        SettlementEvidenceRoleV2::Price
903        | SettlementEvidenceRoleV2::Contract
904        | SettlementEvidenceRoleV2::CustomerApproval => SettlementEvidenceClassV2::Declared,
905    }
906}
907
908fn eligibility(
909    manifest: &SettlementEvidenceManifestV2,
910    trust_store: &SettlementEvidenceTrustStoreV2,
911    mut reasons: Vec<SettlementIneligibilityReasonV2>,
912    active_evidence_count: usize,
913    attributed_tokens: Option<u64>,
914    attributed_minor_units: Option<u64>,
915) -> SettlementEligibilityV2 {
916    reasons.sort();
917    reasons.dedup();
918    SettlementEligibilityV2 {
919        schema_version: SETTLEMENT_EVIDENCE_SCHEMA_VERSION,
920        manifest_id: bounded_result_id(&manifest.manifest_id, "manifest:invalid"),
921        trust_store_id: bounded_result_id(&trust_store.trust_store_id, "trust-store:invalid"),
922        eligible: reasons.is_empty(),
923        reasons,
924        evidence_count: manifest.evidence.len(),
925        active_evidence_count,
926        attributed_tokens,
927        attributed_minor_units,
928        invoice_authority: false,
929        contract_validity_verified: false,
930        customer_approval_authority_verified: false,
931    }
932}
933
934fn bounded_result_id(value: &str, invalid: &str) -> String {
935    if value.len() <= MAX_SETTLEMENT_STRING_BYTES {
936        value.to_string()
937    } else {
938        invalid.to_string()
939    }
940}
941
942fn address_mismatch(computed: Result<String, SettlementEvidenceError>, expected: &str) -> bool {
943    computed.map_or(true, |value| value != expected)
944}
945
946fn bound_error_reason(
947    error: &SettlementEvidenceError,
948    trust_store: bool,
949) -> SettlementIneligibilityReasonV2 {
950    match error {
951        SettlementEvidenceError::TooManyEvidenceItems => {
952            SettlementIneligibilityReasonV2::TooManyEvidenceItems
953        }
954        SettlementEvidenceError::TooManyTrustDecisions => {
955            SettlementIneligibilityReasonV2::TooManyTrustDecisions
956        }
957        SettlementEvidenceError::TooManyAttributionSources => {
958            SettlementIneligibilityReasonV2::TooManyAttributionSources
959        }
960        SettlementEvidenceError::TooManySupersessionRefs => {
961            SettlementIneligibilityReasonV2::TooManySupersessionRefs
962        }
963        SettlementEvidenceError::OversizedString => {
964            SettlementIneligibilityReasonV2::OversizedString
965        }
966        SettlementEvidenceError::ManifestTooLarge => {
967            SettlementIneligibilityReasonV2::ManifestTooLarge
968        }
969        _ if trust_store => SettlementIneligibilityReasonV2::InvalidTrustStore,
970        _ => SettlementIneligibilityReasonV2::InvalidManifestId,
971    }
972}
973
974fn valid_address(value: &str, prefix: &str) -> bool {
975    value.strip_prefix(prefix).is_some_and(|digest| {
976        digest.len() == 64
977            && digest
978                .bytes()
979                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
980    })
981}
982
983fn valid_iso_currency(currency: &str) -> bool {
984    currency.len() == 3 && currency.bytes().all(|byte| byte.is_ascii_uppercase())
985}
986
987fn ensure_string_bound(value: &str) -> Result<(), SettlementEvidenceError> {
988    if value.len() > MAX_SETTLEMENT_STRING_BYTES {
989        return Err(SettlementEvidenceError::OversizedString);
990    }
991    Ok(())
992}
993
994fn ensure_item_structure(item: &SettlementEvidenceItemV2) -> Result<(), SettlementEvidenceError> {
995    if item.supersedes.len() > MAX_SUPERSESSION_REFS {
996        return Err(SettlementEvidenceError::TooManySupersessionRefs);
997    }
998    for value in [
999        item.evidence_id.as_str(),
1000        item.subject_id.as_str(),
1001        item.trust.trust_decision_id.as_str(),
1002        item.trust.trust_anchor_id.as_str(),
1003        item.measurement.method_artifact_id.as_str(),
1004    ] {
1005        ensure_string_bound(value)?;
1006    }
1007    if let Some(reason) = item.correction_reason_id.as_deref() {
1008        ensure_string_bound(reason)?;
1009    }
1010    for target in &item.supersedes {
1011        ensure_string_bound(target)?;
1012    }
1013    match &item.claim {
1014        SettlementEvidenceClaimV2::Baseline {
1015            baseline_version_id,
1016            ..
1017        } => ensure_string_bound(baseline_version_id)?,
1018        SettlementEvidenceClaimV2::Price {
1019            price_version_id,
1020            currency,
1021            ..
1022        } => {
1023            ensure_string_bound(price_version_id)?;
1024            ensure_string_bound(currency)?;
1025        }
1026        SettlementEvidenceClaimV2::Contract {
1027            contract_version_id,
1028        } => ensure_string_bound(contract_version_id)?,
1029        SettlementEvidenceClaimV2::Quality {
1030            quality_gate_id, ..
1031        } => ensure_string_bound(quality_gate_id)?,
1032        SettlementEvidenceClaimV2::Attribution {
1033            mechanism_id,
1034            source_evidence_ids,
1035            ..
1036        } => {
1037            if source_evidence_ids.len() > MAX_ATTRIBUTION_SOURCE_IDS {
1038                return Err(SettlementEvidenceError::TooManyAttributionSources);
1039            }
1040            ensure_string_bound(mechanism_id)?;
1041            for source in source_evidence_ids {
1042                ensure_string_bound(source)?;
1043            }
1044        }
1045        SettlementEvidenceClaimV2::PeriodCompletion { .. } => {}
1046        SettlementEvidenceClaimV2::CustomerApproval {
1047            approval_artifact_id,
1048            ..
1049        } => ensure_string_bound(approval_artifact_id)?,
1050    }
1051    Ok(())
1052}
1053
1054fn ensure_item_bounds(item: &SettlementEvidenceItemV2) -> Result<(), SettlementEvidenceError> {
1055    ensure_item_structure(item)?;
1056    ensure_serialized_bound(item)
1057}
1058
1059fn ensure_manifest_bounds(
1060    manifest: &SettlementEvidenceManifestV2,
1061) -> Result<(), SettlementEvidenceError> {
1062    if manifest.evidence.len() > MAX_SETTLEMENT_EVIDENCE_ITEMS {
1063        return Err(SettlementEvidenceError::TooManyEvidenceItems);
1064    }
1065    for value in [
1066        manifest.kind.as_str(),
1067        manifest.manifest_id.as_str(),
1068        manifest.subject_id.as_str(),
1069        manifest.currency.as_str(),
1070    ] {
1071        ensure_string_bound(value)?;
1072    }
1073    let mut total_sources = 0usize;
1074    for item in &manifest.evidence {
1075        ensure_item_structure(item)?;
1076        if let SettlementEvidenceClaimV2::Attribution {
1077            source_evidence_ids,
1078            ..
1079        } = &item.claim
1080        {
1081            total_sources = total_sources
1082                .checked_add(source_evidence_ids.len())
1083                .ok_or(SettlementEvidenceError::TooManyAttributionSources)?;
1084            if total_sources > MAX_ATTRIBUTION_SOURCE_IDS {
1085                return Err(SettlementEvidenceError::TooManyAttributionSources);
1086            }
1087        }
1088    }
1089    ensure_serialized_bound(manifest)
1090}
1091
1092fn ensure_trust_store_bounds(
1093    store: &SettlementEvidenceTrustStoreV2,
1094) -> Result<(), SettlementEvidenceError> {
1095    if store.trusted_decisions.len() > MAX_SETTLEMENT_TRUST_DECISIONS {
1096        return Err(SettlementEvidenceError::TooManyTrustDecisions);
1097    }
1098    ensure_string_bound(&store.trust_store_id)?;
1099    for decision in &store.trusted_decisions {
1100        for value in [
1101            decision.evidence_id.as_str(),
1102            decision.trust_decision_id.as_str(),
1103            decision.trust_anchor_id.as_str(),
1104        ] {
1105            ensure_string_bound(value)?;
1106        }
1107    }
1108    ensure_serialized_bound(store)
1109}
1110
1111struct BoundedCountWriter {
1112    written: u64,
1113}
1114
1115impl Write for BoundedCountWriter {
1116    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1117        let next = self
1118            .written
1119            .checked_add(bytes.len() as u64)
1120            .ok_or_else(|| std::io::Error::other("settlement evidence size overflow"))?;
1121        if next > MAX_SETTLEMENT_MANIFEST_BYTES {
1122            return Err(std::io::Error::other(
1123                "settlement evidence exceeds bounded size",
1124            ));
1125        }
1126        self.written = next;
1127        Ok(bytes.len())
1128    }
1129
1130    fn flush(&mut self) -> std::io::Result<()> {
1131        Ok(())
1132    }
1133}
1134
1135fn ensure_serialized_bound<T: Serialize>(value: &T) -> Result<(), SettlementEvidenceError> {
1136    let mut writer = BoundedCountWriter { written: 0 };
1137    serde_json::to_writer(&mut writer, value).map_err(|_| SettlementEvidenceError::ManifestTooLarge)
1138}
1139
1140struct BoundedHashWriter {
1141    written: u64,
1142    hasher: blake3::Hasher,
1143}
1144
1145impl Write for BoundedHashWriter {
1146    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1147        let next = self
1148            .written
1149            .checked_add(bytes.len() as u64)
1150            .ok_or_else(|| std::io::Error::other("settlement evidence size overflow"))?;
1151        if next > MAX_SETTLEMENT_MANIFEST_BYTES {
1152            return Err(std::io::Error::other(
1153                "settlement evidence exceeds bounded size",
1154            ));
1155        }
1156        self.hasher.update(bytes);
1157        self.written = next;
1158        Ok(bytes.len())
1159    }
1160
1161    fn flush(&mut self) -> std::io::Result<()> {
1162        Ok(())
1163    }
1164}
1165
1166fn hash_bounded_json<T: Serialize>(value: &T) -> Result<blake3::Hash, SettlementEvidenceError> {
1167    let mut writer = BoundedHashWriter {
1168        written: 0,
1169        hasher: blake3::Hasher::new(),
1170    };
1171    serde_json::to_writer(&mut writer, value).map_err(SettlementEvidenceError::Serialize)?;
1172    Ok(writer.hasher.finalize())
1173}
1174
1175fn read_json_bounded<T: serde::de::DeserializeOwned>(
1176    path: &Path,
1177) -> Result<T, SettlementEvidenceError> {
1178    let lstat = std::fs::symlink_metadata(path).map_err(SettlementEvidenceError::Io)?;
1179    if lstat.file_type().is_symlink() || !lstat.is_file() {
1180        return Err(SettlementEvidenceError::UnsafePath);
1181    }
1182    let mut options = std::fs::OpenOptions::new();
1183    options.read(true);
1184    #[cfg(unix)]
1185    options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
1186    let file = options.open(path).map_err(SettlementEvidenceError::Io)?;
1187    let fstat = file.metadata().map_err(SettlementEvidenceError::Io)?;
1188    if !fstat.is_file() {
1189        return Err(SettlementEvidenceError::UnsafePath);
1190    }
1191    if fstat.len() > MAX_SETTLEMENT_MANIFEST_BYTES {
1192        return Err(SettlementEvidenceError::ManifestTooLarge);
1193    }
1194    let mut body = String::with_capacity((fstat.len() + 1) as usize);
1195    file.take(MAX_SETTLEMENT_MANIFEST_BYTES + 1)
1196        .read_to_string(&mut body)
1197        .map_err(SettlementEvidenceError::Io)?;
1198    if body.len() as u64 > MAX_SETTLEMENT_MANIFEST_BYTES {
1199        return Err(SettlementEvidenceError::ManifestTooLarge);
1200    }
1201    serde_json::from_str(&body).map_err(SettlementEvidenceError::Deserialize)
1202}
1203
1204struct TempExportGuard(Option<PathBuf>);
1205
1206impl Drop for TempExportGuard {
1207    fn drop(&mut self) {
1208        if let Some(path) = self.0.take() {
1209            let _ = std::fs::remove_file(path);
1210        }
1211    }
1212}
1213
1214fn atomic_write_no_symlink(path: &Path, body: &[u8]) -> Result<(), SettlementEvidenceError> {
1215    if body.len() as u64 > MAX_SETTLEMENT_MANIFEST_BYTES {
1216        return Err(SettlementEvidenceError::ManifestTooLarge);
1217    }
1218    let parent = path
1219        .parent()
1220        .filter(|parent| !parent.as_os_str().is_empty())
1221        .unwrap_or_else(|| Path::new("."));
1222    let parent_meta = std::fs::symlink_metadata(parent).map_err(SettlementEvidenceError::Io)?;
1223    if parent_meta.file_type().is_symlink() || !parent_meta.is_dir() {
1224        return Err(SettlementEvidenceError::UnsafePath);
1225    }
1226    let file_name = path
1227        .file_name()
1228        .and_then(|name| name.to_str())
1229        .filter(|name| !name.is_empty() && name.len() <= 255)
1230        .ok_or(SettlementEvidenceError::UnsafePath)?;
1231    validate_export_target(path)?;
1232
1233    let sequence = ATOMIC_EXPORT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1234    let temp_path = parent.join(format!(
1235        ".{file_name}.settlement-{}-{sequence}.tmp",
1236        std::process::id()
1237    ));
1238    let mut options = std::fs::OpenOptions::new();
1239    options.write(true).create_new(true);
1240    #[cfg(unix)]
1241    options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
1242    let mut file = options
1243        .open(&temp_path)
1244        .map_err(SettlementEvidenceError::Io)?;
1245    let mut guard = TempExportGuard(Some(temp_path.clone()));
1246    file.write_all(body).map_err(SettlementEvidenceError::Io)?;
1247    file.sync_all().map_err(SettlementEvidenceError::Io)?;
1248    drop(file);
1249
1250    validate_export_target(path)?;
1251    std::fs::rename(&temp_path, path).map_err(SettlementEvidenceError::Io)?;
1252    guard.0 = None;
1253    #[cfg(unix)]
1254    std::fs::File::open(parent)
1255        .and_then(|directory| directory.sync_all())
1256        .map_err(SettlementEvidenceError::Io)?;
1257    Ok(())
1258}
1259
1260fn validate_export_target(path: &Path) -> Result<(), SettlementEvidenceError> {
1261    match std::fs::symlink_metadata(path) {
1262        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
1263            Err(SettlementEvidenceError::UnsafePath)
1264        }
1265        Ok(_) => Ok(()),
1266        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1267        Err(error) => Err(SettlementEvidenceError::Io(error)),
1268    }
1269}
1270
1271#[derive(Debug, thiserror::Error)]
1272pub enum SettlementEvidenceError {
1273    #[error("settlement evidence manifest exceeds the bounded byte limit")]
1274    ManifestTooLarge,
1275    #[error("settlement evidence manifest exceeds the bounded item limit")]
1276    TooManyEvidenceItems,
1277    #[error("settlement evidence trust store exceeds the bounded decision limit")]
1278    TooManyTrustDecisions,
1279    #[error("settlement evidence attribution sources exceed the bounded limit")]
1280    TooManyAttributionSources,
1281    #[error("settlement evidence supersession references exceed the bounded limit")]
1282    TooManySupersessionRefs,
1283    #[error("settlement evidence string exceeds the bounded limit")]
1284    OversizedString,
1285    #[error("settlement evidence content address does not match canonical content")]
1286    IntegrityMismatch,
1287    #[error("settlement evidence path is a symlink or unsafe file type")]
1288    UnsafePath,
1289    #[error("settlement evidence I/O failed: {0}")]
1290    Io(std::io::Error),
1291    #[error("settlement evidence serialization failed: {0}")]
1292    Serialize(serde_json::Error),
1293    #[error("settlement evidence deserialization failed: {0}")]
1294    Deserialize(serde_json::Error),
1295}
1296
1297#[cfg(test)]
1298mod tests;