1use 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#[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#[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#[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#[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 #[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#[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#[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#[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 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#[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 pub currency: String,
344 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 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 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 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#[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#[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#[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;