Skip to main content

asupersync/atp/diagnostics/
mod.rs

1//! ATP runtime-evidence diagnostics bridge.
2//!
3//! The bridge turns Asupersync runtime evidence into ATP-facing diagnostic
4//! documents. Runtime facts that prove a protocol/runtime invariant are kept
5//! separate from advisory risk signals such as spectral health, conformal
6//! bounds, and e-process alerts.
7
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10
11/// Stable schema for ATP runtime-evidence diagnostic envelopes.
12pub const ATP_RUNTIME_EVIDENCE_DIAGNOSTIC_SCHEMA: &str =
13    "asupersync.atp.diagnostics.runtime_evidence.v1";
14
15/// Stable schema for rendered ATP runtime-evidence explanations.
16pub const ATP_RUNTIME_EVIDENCE_EXPLANATION_SCHEMA: &str =
17    "asupersync.atp.diagnostics.runtime_explanation.v1";
18
19/// Stable schema for ATP practical network-truth pressure evidence.
20pub const ATP_NETWORK_TRUTH_PRESSURE_SCHEMA: &str =
21    "asupersync.atp.diagnostics.network_truth_pressure.v1";
22
23/// Maximum network-truth signals carried by one diagnostic envelope.
24pub const ATP_NETWORK_TRUTH_MAX_SIGNALS: usize = 16;
25
26const REDACTED: &str = "<redacted>";
27
28/// Classification for a runtime evidence signal.
29#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum AtpRuntimeSignalClass {
32    /// Evidence that directly proves an ATP protocol invariant.
33    ProtocolProof,
34    /// Evidence that directly proves an Asupersync runtime invariant.
35    RuntimeProof,
36    /// Calibrated or heuristic evidence that must not be worded as proof.
37    AdvisoryRisk,
38    /// Signal was expected but unavailable for this transfer.
39    Unavailable,
40}
41
42impl AtpRuntimeSignalClass {
43    /// Returns true when this signal may appear in `proof_claims`.
44    #[must_use]
45    pub const fn is_proof(self) -> bool {
46        matches!(self, Self::ProtocolProof | Self::RuntimeProof)
47    }
48
49    /// Stable string label.
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::ProtocolProof => "protocol_proof",
54            Self::RuntimeProof => "runtime_proof",
55            Self::AdvisoryRisk => "advisory_risk",
56            Self::Unavailable => "unavailable",
57        }
58    }
59}
60
61/// Source family for ATP runtime evidence.
62#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum AtpRuntimeSignalSource {
65    /// Cx/region identity and structured-concurrency ownership.
66    CxRegion,
67    /// Transfer actor lifecycle identity.
68    TransferActor,
69    /// Obligation and futurelock accounting.
70    ObligationTracker,
71    /// Cancellation drain/finalizer evidence.
72    CancellationDrain,
73    /// Deterministic lab replay or crashpack pointer.
74    ReplayCrashpack,
75    /// Spectral wait-graph health.
76    SpectralWaitGraph,
77    /// Conformal calibration bound.
78    ConformalAlert,
79    /// Anytime-valid e-process alert.
80    EProcessAlert,
81    /// FrankenEvidence or decision-ledger row.
82    EvidenceLedger,
83}
84
85impl AtpRuntimeSignalSource {
86    /// Stable string label.
87    #[must_use]
88    pub const fn as_str(self) -> &'static str {
89        match self {
90            Self::CxRegion => "cx_region",
91            Self::TransferActor => "transfer_actor",
92            Self::ObligationTracker => "obligation_tracker",
93            Self::CancellationDrain => "cancellation_drain",
94            Self::ReplayCrashpack => "replay_crashpack",
95            Self::SpectralWaitGraph => "spectral_wait_graph",
96            Self::ConformalAlert => "conformal_alert",
97            Self::EProcessAlert => "eprocess_alert",
98            Self::EvidenceLedger => "evidence_ledger",
99        }
100    }
101}
102
103/// Evidence quality for one practical network-truth signal.
104#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum AtpNetworkTruthSignalKind {
107    /// Directly measured by ATP or the runtime.
108    MeasuredFact,
109    /// Inferred from multiple measured facts.
110    InferredPressure,
111    /// Useful estimate that must not be presented as measured fact.
112    AdvisoryEstimate,
113    /// Expected signal is unavailable on this platform/path.
114    Unsupported,
115}
116
117impl AtpNetworkTruthSignalKind {
118    /// Stable string label.
119    #[must_use]
120    pub const fn as_str(self) -> &'static str {
121        match self {
122            Self::MeasuredFact => "measured_fact",
123            Self::InferredPressure => "inferred_pressure",
124            Self::AdvisoryEstimate => "advisory_estimate",
125            Self::Unsupported => "unsupported",
126        }
127    }
128}
129
130/// Practical network-truth metric families used by ATP diagnostics.
131#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum AtpNetworkTruthMetric {
134    /// Round-trip time.
135    Rtt,
136    /// ACK delay.
137    AckDelay,
138    /// Packet or symbol loss.
139    Loss,
140    /// Probe timeout pressure.
141    Pto,
142    /// Congestion window, when available.
143    CongestionWindow,
144    /// Bytes in flight, when available.
145    BytesInFlight,
146    /// Socket send/receive pressure.
147    SocketPressure,
148    /// Disk write/read lag pressure.
149    DiskLag,
150    /// CPU pressure from encode/decode work.
151    CpuEncodeDecodePressure,
152    /// Repair return-on-investment estimate.
153    RepairRoi,
154    /// Relay-vs-direct path delta.
155    RelayDirectDelta,
156    /// Path migration events.
157    PathMigration,
158    /// Cancellation pressure.
159    CancellationPressure,
160    /// Obligation drain latency.
161    ObligationDrainLatency,
162}
163
164impl AtpNetworkTruthMetric {
165    /// Every required metric family in deterministic order.
166    pub const ALL: [Self; 14] = [
167        Self::Rtt,
168        Self::AckDelay,
169        Self::Loss,
170        Self::Pto,
171        Self::CongestionWindow,
172        Self::BytesInFlight,
173        Self::SocketPressure,
174        Self::DiskLag,
175        Self::CpuEncodeDecodePressure,
176        Self::RepairRoi,
177        Self::RelayDirectDelta,
178        Self::PathMigration,
179        Self::CancellationPressure,
180        Self::ObligationDrainLatency,
181    ];
182
183    /// Stable string label.
184    #[must_use]
185    pub const fn as_str(self) -> &'static str {
186        match self {
187            Self::Rtt => "rtt",
188            Self::AckDelay => "ack_delay",
189            Self::Loss => "loss",
190            Self::Pto => "pto",
191            Self::CongestionWindow => "congestion_window",
192            Self::BytesInFlight => "bytes_in_flight",
193            Self::SocketPressure => "socket_pressure",
194            Self::DiskLag => "disk_lag",
195            Self::CpuEncodeDecodePressure => "cpu_encode_decode_pressure",
196            Self::RepairRoi => "repair_roi",
197            Self::RelayDirectDelta => "relay_direct_delta",
198            Self::PathMigration => "path_migration",
199            Self::CancellationPressure => "cancellation_pressure",
200            Self::ObligationDrainLatency => "obligation_drain_latency",
201        }
202    }
203}
204
205/// Aggregated ATP network pressure level.
206#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum AtpNetworkPressureLevel {
209    /// No meaningful pressure detected.
210    Nominal,
211    /// Pressure is visible but not yet transfer-degrading.
212    Watch,
213    /// Pressure is likely affecting transfer quality.
214    Degraded,
215    /// Pressure is high enough to require conservative behavior.
216    Critical,
217}
218
219impl AtpNetworkPressureLevel {
220    /// Stable string label.
221    #[must_use]
222    pub const fn as_str(self) -> &'static str {
223        match self {
224            Self::Nominal => "nominal",
225            Self::Watch => "watch",
226            Self::Degraded => "degraded",
227            Self::Critical => "critical",
228        }
229    }
230}
231
232/// One practical network-truth signal for ATP diagnostics.
233#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
234pub struct AtpNetworkTruthSignal {
235    /// Metric family.
236    pub metric: AtpNetworkTruthMetric,
237    /// Evidence quality.
238    pub kind: AtpNetworkTruthSignalKind,
239    /// Integer value in the declared unit, if available.
240    pub value: Option<i64>,
241    /// Stable unit label, such as `micros`, `permille`, `bytes`, or `ppm`.
242    pub unit: String,
243    /// Pressure contribution in parts-per-million, clamped to 0..=1_000_000.
244    pub pressure_score_ppm: u32,
245    /// Source reference, such as a pathlog row or proof bundle id.
246    pub source_ref: Option<String>,
247    /// Short operator-facing detail.
248    pub detail: Option<String>,
249}
250
251impl AtpNetworkTruthSignal {
252    /// Builds a network-truth signal.
253    #[must_use]
254    pub fn new(
255        metric: AtpNetworkTruthMetric,
256        kind: AtpNetworkTruthSignalKind,
257        value: Option<i64>,
258        unit: impl Into<String>,
259        pressure_score_ppm: u32,
260        source_ref: Option<String>,
261        detail: Option<String>,
262    ) -> Self {
263        Self {
264            metric,
265            kind,
266            value,
267            unit: unit.into(),
268            pressure_score_ppm: pressure_score_ppm.min(1_000_000),
269            source_ref,
270            detail,
271        }
272    }
273
274    /// Builds a directly measured fact.
275    #[must_use]
276    pub fn measured(
277        metric: AtpNetworkTruthMetric,
278        value: i64,
279        unit: impl Into<String>,
280        pressure_score_ppm: u32,
281        source_ref: impl Into<String>,
282    ) -> Self {
283        Self::new(
284            metric,
285            AtpNetworkTruthSignalKind::MeasuredFact,
286            Some(value),
287            unit,
288            pressure_score_ppm,
289            Some(source_ref.into()),
290            None,
291        )
292    }
293
294    /// Builds an inferred pressure signal.
295    #[must_use]
296    pub fn inferred(
297        metric: AtpNetworkTruthMetric,
298        value: i64,
299        unit: impl Into<String>,
300        pressure_score_ppm: u32,
301        source_ref: impl Into<String>,
302        detail: impl Into<String>,
303    ) -> Self {
304        Self::new(
305            metric,
306            AtpNetworkTruthSignalKind::InferredPressure,
307            Some(value),
308            unit,
309            pressure_score_ppm,
310            Some(source_ref.into()),
311            Some(detail.into()),
312        )
313    }
314
315    /// Builds an advisory estimate.
316    #[must_use]
317    pub fn advisory(
318        metric: AtpNetworkTruthMetric,
319        value: i64,
320        unit: impl Into<String>,
321        pressure_score_ppm: u32,
322        source_ref: impl Into<String>,
323        detail: impl Into<String>,
324    ) -> Self {
325        Self::new(
326            metric,
327            AtpNetworkTruthSignalKind::AdvisoryEstimate,
328            Some(value),
329            unit,
330            pressure_score_ppm,
331            Some(source_ref.into()),
332            Some(detail.into()),
333        )
334    }
335
336    /// Builds an unsupported-signal downgrade.
337    #[must_use]
338    pub fn unsupported(metric: AtpNetworkTruthMetric, reason: impl Into<String>) -> Self {
339        Self::new(
340            metric,
341            AtpNetworkTruthSignalKind::Unsupported,
342            None,
343            "unsupported",
344            0,
345            None,
346            Some(reason.into()),
347        )
348    }
349}
350
351/// Practical network-truth pressure evidence attached to ATP diagnostics.
352#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
353pub struct AtpNetworkTruthPressureModel {
354    /// Stable schema version.
355    pub schema_version: String,
356    /// Path or route id, when available.
357    pub path_id: Option<String>,
358    /// Deterministic lab/runtime timestamp in microseconds.
359    pub deterministic_timestamp_micros: u64,
360    /// Bounded metric signals.
361    pub signals: Vec<AtpNetworkTruthSignal>,
362    /// Redaction policy applied before user display.
363    pub redaction_policy: String,
364}
365
366impl AtpNetworkTruthPressureModel {
367    /// Creates an empty pressure model at a deterministic timestamp.
368    #[must_use]
369    pub fn new(deterministic_timestamp_micros: u64) -> Self {
370        Self {
371            schema_version: ATP_NETWORK_TRUTH_PRESSURE_SCHEMA.to_string(),
372            path_id: None,
373            deterministic_timestamp_micros,
374            signals: Vec::new(),
375            redaction_policy: "atp-network-truth-default".to_string(),
376        }
377    }
378
379    /// Adds a signal if the model has remaining cardinality budget.
380    pub fn add_signal(&mut self, signal: AtpNetworkTruthSignal) -> bool {
381        if self.signals.len() >= ATP_NETWORK_TRUTH_MAX_SIGNALS {
382            return false;
383        }
384        self.signals.push(signal);
385        true
386    }
387
388    /// Highest pressure contribution in parts-per-million.
389    #[must_use]
390    pub fn overall_pressure_score_ppm(&self) -> u32 {
391        self.signals
392            .iter()
393            .map(|signal| signal.pressure_score_ppm)
394            .max()
395            .unwrap_or(0)
396    }
397
398    /// Pressure level without hysteresis.
399    #[must_use]
400    pub fn pressure_level(&self) -> AtpNetworkPressureLevel {
401        pressure_level_for_score(self.overall_pressure_score_ppm())
402    }
403
404    /// Pressure level with conservative downshift hysteresis.
405    #[must_use]
406    pub fn pressure_level_with_hysteresis(
407        &self,
408        previous: Option<AtpNetworkPressureLevel>,
409    ) -> AtpNetworkPressureLevel {
410        let candidate = self.pressure_level();
411        let Some(previous) = previous else {
412            return candidate;
413        };
414        if previous > candidate && self.overall_pressure_score_ppm() >= retention_floor(previous) {
415            previous
416        } else {
417            candidate
418        }
419    }
420
421    /// Metrics represented as unsupported by this model.
422    #[must_use]
423    pub fn unsupported_metrics(&self) -> Vec<AtpNetworkTruthMetric> {
424        let mut unsupported = self
425            .signals
426            .iter()
427            .filter(|signal| signal.kind == AtpNetworkTruthSignalKind::Unsupported)
428            .map(|signal| signal.metric)
429            .collect::<Vec<_>>();
430        unsupported.sort();
431        unsupported.dedup();
432        unsupported
433    }
434
435    /// Required metrics absent from this model.
436    #[must_use]
437    pub fn missing_required_metrics(&self) -> Vec<AtpNetworkTruthMetric> {
438        let present = self
439            .signals
440            .iter()
441            .map(|signal| signal.metric)
442            .collect::<BTreeSet<_>>();
443        AtpNetworkTruthMetric::ALL
444            .into_iter()
445            .filter(|metric| !present.contains(metric))
446            .collect()
447    }
448
449    /// Concise operator-facing summary.
450    #[must_use]
451    pub fn summary_line(&self) -> String {
452        format!(
453            "network truth pressure {} score_ppm={} signals={} unsupported={} missing={}",
454            self.pressure_level().as_str(),
455            self.overall_pressure_score_ppm(),
456            self.signals.len(),
457            self.unsupported_metrics().len(),
458            self.missing_required_metrics().len()
459        )
460    }
461
462    /// Returns a user-safe copy with correlation ids and details redacted.
463    #[must_use]
464    pub fn redacted_for_user(&self) -> Self {
465        let mut redacted = self.clone();
466        redacted.path_id = redacted.path_id.as_deref().map(redact_token);
467        for signal in &mut redacted.signals {
468            signal.source_ref = signal.source_ref.as_deref().map(redact_token);
469            signal.detail = signal.detail.as_deref().map(|_| REDACTED.to_string());
470        }
471        redacted.redaction_policy = format!("{}+user_safe", self.redaction_policy);
472        redacted
473    }
474}
475
476fn pressure_level_for_score(score_ppm: u32) -> AtpNetworkPressureLevel {
477    match score_ppm {
478        0..=200_000 => AtpNetworkPressureLevel::Nominal,
479        200_001..=600_000 => AtpNetworkPressureLevel::Watch,
480        600_001..=850_000 => AtpNetworkPressureLevel::Degraded,
481        _ => AtpNetworkPressureLevel::Critical,
482    }
483}
484
485fn retention_floor(level: AtpNetworkPressureLevel) -> u32 {
486    match level {
487        AtpNetworkPressureLevel::Nominal => 0,
488        AtpNetworkPressureLevel::Watch => 150_000,
489        AtpNetworkPressureLevel::Degraded => 500_000,
490        AtpNetworkPressureLevel::Critical => 750_000,
491    }
492}
493
494/// Obligation and futurelock counts captured for one ATP transfer.
495#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
496pub struct AtpObligationEvidenceCounts {
497    /// Number of obligations created by the transfer session.
498    pub created: u64,
499    /// Number of obligations committed successfully.
500    pub committed: u64,
501    /// Number of obligations aborted during cleanup.
502    pub aborted: u64,
503    /// Number of obligations still outstanding at diagnostic time.
504    pub outstanding: u64,
505    /// Number of futurelock or wait-for edges observed.
506    pub futurelock_waiters: u64,
507}
508
509impl AtpObligationEvidenceCounts {
510    /// Returns true if obligation accounting proves no outstanding obligation.
511    #[must_use]
512    pub const fn proves_no_obligation_leak(&self) -> bool {
513        self.outstanding == 0 && self.created == self.committed.saturating_add(self.aborted)
514    }
515}
516
517/// Cancellation drain evidence for an ATP transfer.
518#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
519pub struct AtpCancellationDrainEvidence {
520    /// Whether cancellation was requested.
521    pub requested: bool,
522    /// Whether all loser/child work drained.
523    pub drained: bool,
524    /// Number of loser tasks drained after cancellation or path race.
525    pub losers_drained: u64,
526    /// Deterministic drain certificate id, when available.
527    pub drain_certificate_id: Option<String>,
528    /// Short machine-readable reason.
529    pub reason: String,
530}
531
532impl AtpCancellationDrainEvidence {
533    /// Returns true when cancellation evidence is a runtime proof.
534    #[must_use]
535    pub const fn proves_drain(&self) -> bool {
536        !self.requested || self.drained
537    }
538}
539
540/// Finalizer outcome captured for one ATP transfer.
541#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
542pub struct AtpFinalizerEvidence {
543    /// Whether finalizers ran.
544    pub ran: bool,
545    /// Whether finalizers completed successfully.
546    pub completed: bool,
547    /// Short finalizer status label.
548    pub outcome: String,
549}
550
551/// Deterministic replay or crashpack pointer for ATP diagnostics.
552#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
553pub struct AtpReplayEvidencePointer {
554    /// Trace identifier, if the runtime provided one.
555    pub trace_id: Option<String>,
556    /// Crashpack identifier, if one was emitted.
557    pub crashpack_id: Option<String>,
558    /// Exact replay command for this diagnostic.
559    pub replay_command: String,
560    /// Redaction policy used for replay artifacts.
561    pub redaction_policy: String,
562}
563
564/// One runtime evidence signal attached to an ATP diagnostic.
565#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
566pub struct AtpRuntimeEvidenceSignal {
567    /// Stable signal id within the transfer.
568    pub signal_id: String,
569    /// Source family for the signal.
570    pub source: AtpRuntimeSignalSource,
571    /// Proof/advisory/unavailable classification.
572    pub class: AtpRuntimeSignalClass,
573    /// Short operator-facing summary.
574    pub summary: String,
575    /// Machine-readable evidence reference, when available.
576    pub evidence_ref: Option<String>,
577    /// Explicit reason when the signal is unavailable.
578    pub unavailable_reason: Option<String>,
579}
580
581impl AtpRuntimeEvidenceSignal {
582    /// Builds a proof-bearing runtime evidence signal.
583    #[must_use]
584    pub fn proof(
585        signal_id: impl Into<String>,
586        source: AtpRuntimeSignalSource,
587        summary: impl Into<String>,
588        evidence_ref: impl Into<String>,
589    ) -> Self {
590        Self {
591            signal_id: signal_id.into(),
592            source,
593            class: AtpRuntimeSignalClass::RuntimeProof,
594            summary: summary.into(),
595            evidence_ref: Some(evidence_ref.into()),
596            unavailable_reason: None,
597        }
598    }
599
600    /// Builds an advisory risk signal.
601    #[must_use]
602    pub fn advisory(
603        signal_id: impl Into<String>,
604        source: AtpRuntimeSignalSource,
605        summary: impl Into<String>,
606        evidence_ref: impl Into<String>,
607    ) -> Self {
608        Self {
609            signal_id: signal_id.into(),
610            source,
611            class: AtpRuntimeSignalClass::AdvisoryRisk,
612            summary: summary.into(),
613            evidence_ref: Some(evidence_ref.into()),
614            unavailable_reason: None,
615        }
616    }
617
618    /// Builds an unavailable-signal downgrade entry.
619    #[must_use]
620    pub fn unavailable(
621        signal_id: impl Into<String>,
622        source: AtpRuntimeSignalSource,
623        reason: impl Into<String>,
624    ) -> Self {
625        let reason = reason.into();
626        Self {
627            signal_id: signal_id.into(),
628            source,
629            class: AtpRuntimeSignalClass::Unavailable,
630            summary: format!("{} unavailable: {reason}", source.as_str()),
631            evidence_ref: None,
632            unavailable_reason: Some(reason),
633        }
634    }
635}
636
637/// Structured runtime evidence envelope carried by ATP diagnostics.
638#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
639pub struct AtpRuntimeEvidenceEnvelope {
640    /// Stable schema version.
641    pub schema_version: String,
642    /// ATP transfer id.
643    pub transfer_id: String,
644    /// Cx region id that owns the transfer, when available.
645    pub cx_region_id: Option<String>,
646    /// Transfer actor id, when available.
647    pub transfer_actor_id: Option<String>,
648    /// Obligation/futurelock counts.
649    pub obligation_counts: AtpObligationEvidenceCounts,
650    /// Cancellation drain evidence.
651    pub cancellation: Option<AtpCancellationDrainEvidence>,
652    /// Finalizer evidence.
653    pub finalizer: Option<AtpFinalizerEvidence>,
654    /// Deterministic replay or crashpack pointer.
655    pub replay: Option<AtpReplayEvidencePointer>,
656    /// Runtime evidence signals.
657    pub signals: Vec<AtpRuntimeEvidenceSignal>,
658    /// Practical network-truth pressure evidence.
659    pub network_truth: Option<AtpNetworkTruthPressureModel>,
660    /// Redaction policy applied before user display.
661    pub redaction_policy: String,
662}
663
664impl AtpRuntimeEvidenceEnvelope {
665    /// Creates a new ATP runtime evidence envelope.
666    #[must_use]
667    pub fn new(transfer_id: impl Into<String>) -> Self {
668        Self {
669            schema_version: ATP_RUNTIME_EVIDENCE_DIAGNOSTIC_SCHEMA.to_string(),
670            transfer_id: transfer_id.into(),
671            cx_region_id: None,
672            transfer_actor_id: None,
673            obligation_counts: AtpObligationEvidenceCounts::default(),
674            cancellation: None,
675            finalizer: None,
676            replay: None,
677            signals: Vec::new(),
678            network_truth: None,
679            redaction_policy: "atp-runtime-evidence-default".to_string(),
680        }
681    }
682
683    /// Returns a user-safe copy of the envelope with correlation ids redacted.
684    #[must_use]
685    pub fn redacted_for_user(&self) -> Self {
686        let mut redacted = self.clone();
687        redacted.transfer_id = redact_token(&redacted.transfer_id);
688        redacted.cx_region_id = redacted.cx_region_id.as_deref().map(redact_token);
689        redacted.transfer_actor_id = redacted.transfer_actor_id.as_deref().map(redact_token);
690        if let Some(replay) = &mut redacted.replay {
691            replay.trace_id = replay.trace_id.as_deref().map(redact_token);
692            replay.crashpack_id = replay.crashpack_id.as_deref().map(redact_token);
693            replay.replay_command = REDACTED.to_string();
694        }
695        for signal in &mut redacted.signals {
696            signal.evidence_ref = signal.evidence_ref.as_deref().map(redact_token);
697        }
698        redacted.network_truth = redacted
699            .network_truth
700            .as_ref()
701            .map(AtpNetworkTruthPressureModel::redacted_for_user);
702        redacted.redaction_policy = format!("{}+user_safe", self.redaction_policy);
703        redacted
704    }
705}
706
707/// User-facing runtime diagnostic document.
708#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
709pub struct AtpRuntimeDiagnosticDocument {
710    /// Stable schema version.
711    pub schema_version: String,
712    /// ATP transfer id.
713    pub transfer_id: String,
714    /// One-line headline.
715    pub headline: String,
716    /// Concise human explanation.
717    pub human_summary: String,
718    /// Claims backed by protocol or runtime proof evidence.
719    pub proof_claims: Vec<String>,
720    /// Advisory risks that must not be described as proof.
721    pub advisory_risks: Vec<String>,
722    /// Practical network-truth explanations that are facts/estimates, not proof claims.
723    pub network_truth_explanations: Vec<String>,
724    /// Signals expected by the diagnostic but unavailable.
725    pub unavailable_signals: Vec<String>,
726    /// Structured evidence envelope used to build the document.
727    pub evidence: AtpRuntimeEvidenceEnvelope,
728}
729
730/// Bridge from runtime evidence envelopes to ATP diagnostic explanations.
731#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
732pub struct AtpRuntimeEvidenceBridge;
733
734impl AtpRuntimeEvidenceBridge {
735    /// Builds a diagnostic document from structured runtime evidence.
736    #[must_use]
737    pub fn explain(envelope: AtpRuntimeEvidenceEnvelope) -> AtpRuntimeDiagnosticDocument {
738        let mut proof_claims = Vec::new();
739        let mut advisory_risks = Vec::new();
740        let mut network_truth_explanations = Vec::new();
741        let mut unavailable_signals = Vec::new();
742
743        if let Some(region_id) = &envelope.cx_region_id {
744            proof_claims.push(format!("transfer is owned by Cx region {region_id}"));
745        }
746        if let Some(actor_id) = &envelope.transfer_actor_id {
747            proof_claims.push(format!("transfer actor {actor_id} is recorded"));
748        }
749        if envelope.obligation_counts.proves_no_obligation_leak() {
750            proof_claims.push(format!(
751                "obligation accounting closed cleanly: created={} committed={} aborted={} outstanding=0",
752                envelope.obligation_counts.created,
753                envelope.obligation_counts.committed,
754                envelope.obligation_counts.aborted
755            ));
756        } else {
757            advisory_risks.push(format!(
758                "obligation accounting is incomplete: outstanding={} futurelock_waiters={}",
759                envelope.obligation_counts.outstanding,
760                envelope.obligation_counts.futurelock_waiters
761            ));
762        }
763        if let Some(cancellation) = &envelope.cancellation {
764            if cancellation.proves_drain() {
765                proof_claims.push(format!(
766                    "cancellation drain completed: requested={} losers_drained={}",
767                    cancellation.requested, cancellation.losers_drained
768                ));
769            } else {
770                advisory_risks.push(format!(
771                    "cancellation requested but drain is not proven: {}",
772                    cancellation.reason
773                ));
774            }
775        }
776        if let Some(finalizer) = &envelope.finalizer {
777            if finalizer.ran && finalizer.completed {
778                proof_claims.push(format!("finalizers completed: {}", finalizer.outcome));
779            } else {
780                advisory_risks.push(format!("finalizers incomplete: {}", finalizer.outcome));
781            }
782        }
783        if let Some(replay) = &envelope.replay {
784            proof_claims.push(format!(
785                "deterministic replay pointer is present: command={}",
786                replay.replay_command
787            ));
788        }
789
790        for signal in &envelope.signals {
791            let line = format!(
792                "{} [{}]: {}",
793                signal.source.as_str(),
794                signal.class.as_str(),
795                signal.summary
796            );
797            match signal.class {
798                class if class.is_proof() => proof_claims.push(line),
799                AtpRuntimeSignalClass::AdvisoryRisk => advisory_risks.push(line),
800                AtpRuntimeSignalClass::Unavailable => {
801                    unavailable_signals.push(signal.unavailable_reason.clone().unwrap_or(line));
802                }
803                AtpRuntimeSignalClass::ProtocolProof | AtpRuntimeSignalClass::RuntimeProof => {
804                    unreachable!("proof classes handled by is_proof")
805                }
806            }
807        }
808        if let Some(network_truth) = &envelope.network_truth {
809            network_truth_explanations.push(network_truth.summary_line());
810            for metric in network_truth.unsupported_metrics() {
811                unavailable_signals.push(format!("network truth {} unsupported", metric.as_str()));
812            }
813            for metric in network_truth.missing_required_metrics() {
814                unavailable_signals.push(format!("network truth {} missing", metric.as_str()));
815            }
816            if network_truth.pressure_level() >= AtpNetworkPressureLevel::Watch {
817                advisory_risks.push(format!(
818                    "network truth pressure is {} (score_ppm={})",
819                    network_truth.pressure_level().as_str(),
820                    network_truth.overall_pressure_score_ppm()
821                ));
822            }
823        }
824
825        proof_claims.sort();
826        proof_claims.dedup();
827        advisory_risks.sort();
828        advisory_risks.dedup();
829        network_truth_explanations.sort();
830        network_truth_explanations.dedup();
831        unavailable_signals.sort();
832        unavailable_signals.dedup();
833
834        let headline = if advisory_risks.is_empty() && unavailable_signals.is_empty() {
835            "ATP runtime evidence supports the transfer explanation".to_string()
836        } else {
837            "ATP runtime evidence includes advisory or unavailable signals".to_string()
838        };
839        let human_summary = render_summary(
840            &headline,
841            &proof_claims,
842            &advisory_risks,
843            &network_truth_explanations,
844        );
845
846        AtpRuntimeDiagnosticDocument {
847            schema_version: ATP_RUNTIME_EVIDENCE_EXPLANATION_SCHEMA.to_string(),
848            transfer_id: envelope.transfer_id.clone(),
849            headline,
850            human_summary,
851            proof_claims,
852            advisory_risks,
853            network_truth_explanations,
854            unavailable_signals,
855            evidence: envelope,
856        }
857    }
858
859    /// Builds a user-safe diagnostic document.
860    #[must_use]
861    pub fn explain_for_user(envelope: &AtpRuntimeEvidenceEnvelope) -> AtpRuntimeDiagnosticDocument {
862        Self::explain(envelope.redacted_for_user())
863    }
864}
865
866fn render_summary(
867    headline: &str,
868    proof_claims: &[String],
869    advisory_risks: &[String],
870    network_truth_explanations: &[String],
871) -> String {
872    let mut parts = vec![headline.to_string()];
873    if let Some(first_proof) = proof_claims.first() {
874        parts.push(format!("Proof: {first_proof}."));
875    }
876    if let Some(first_risk) = advisory_risks.first() {
877        parts.push(format!("Advisory: {first_risk}."));
878    }
879    if let Some(first_network_truth) = network_truth_explanations.first() {
880        parts.push(format!("Network truth: {first_network_truth}."));
881    }
882    parts.join(" ")
883}
884
885fn redact_token(value: &str) -> String {
886    if value.is_empty() {
887        return REDACTED.to_string();
888    }
889    let suffix = value
890        .chars()
891        .rev()
892        .take(6)
893        .collect::<Vec<_>>()
894        .into_iter()
895        .rev()
896        .collect::<String>();
897    format!("{REDACTED}:{suffix}")
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    fn sample_envelope() -> AtpRuntimeEvidenceEnvelope {
905        let mut envelope = AtpRuntimeEvidenceEnvelope::new("transfer-abcdef123456");
906        envelope.cx_region_id = Some("region-root-42".to_string());
907        envelope.transfer_actor_id = Some("actor-send-7".to_string());
908        envelope.obligation_counts = AtpObligationEvidenceCounts {
909            created: 3,
910            committed: 2,
911            aborted: 1,
912            outstanding: 0,
913            futurelock_waiters: 0,
914        };
915        envelope.cancellation = Some(AtpCancellationDrainEvidence {
916            requested: true,
917            drained: true,
918            losers_drained: 2,
919            drain_certificate_id: Some("drain-cert-1".to_string()),
920            reason: "operator_cancel".to_string(),
921        });
922        envelope.finalizer = Some(AtpFinalizerEvidence {
923            ran: true,
924            completed: true,
925            outcome: "all_finalizers_joined".to_string(),
926        });
927        envelope.replay = Some(AtpReplayEvidencePointer {
928            trace_id: Some("trace-123456789".to_string()),
929            crashpack_id: Some("crashpack-abcdef".to_string()),
930            replay_command: "asupersync lab replay trace-123456789 --redacted".to_string(),
931            redaction_policy: "atp-runtime-evidence-default".to_string(),
932        });
933        envelope.signals.push(AtpRuntimeEvidenceSignal::proof(
934            "decision-ledger",
935            AtpRuntimeSignalSource::EvidenceLedger,
936            "decision ledger row binds path choice to transfer evidence",
937            "evidence-ledger-row-1",
938        ));
939        envelope.signals.push(AtpRuntimeEvidenceSignal::advisory(
940            "spectral-risk",
941            AtpRuntimeSignalSource::SpectralWaitGraph,
942            "spectral wait graph is degraded but not a correctness proof",
943            "spectral-report-1",
944        ));
945        envelope.signals.push(AtpRuntimeEvidenceSignal::unavailable(
946            "conformal-risk",
947            AtpRuntimeSignalSource::ConformalAlert,
948            "insufficient calibration window",
949        ));
950        envelope
951    }
952
953    #[test]
954    fn bridge_keeps_proof_claims_separate_from_advisory_risk() {
955        let doc = AtpRuntimeEvidenceBridge::explain(sample_envelope());
956        assert_eq!(doc.schema_version, ATP_RUNTIME_EVIDENCE_EXPLANATION_SCHEMA);
957        assert!(
958            doc.proof_claims
959                .iter()
960                .any(|claim| claim.contains("obligation accounting closed cleanly"))
961        );
962        assert!(
963            doc.proof_claims
964                .iter()
965                .any(|claim| claim.contains("cancellation drain completed"))
966        );
967        assert!(
968            doc.advisory_risks
969                .iter()
970                .any(|risk| risk.contains("spectral_wait_graph [advisory_risk]"))
971        );
972        assert!(
973            doc.proof_claims
974                .iter()
975                .all(|claim| !claim.contains("advisory_risk")),
976            "advisory signals must not be upgraded to proof claims"
977        );
978        assert_eq!(
979            doc.unavailable_signals,
980            vec!["insufficient calibration window".to_string()]
981        );
982    }
983
984    #[test]
985    fn envelope_round_trips_through_json() {
986        let envelope = sample_envelope();
987        let encoded = serde_json::to_string(&envelope).expect("serialize envelope");
988        let decoded: AtpRuntimeEvidenceEnvelope =
989            serde_json::from_str(&encoded).expect("deserialize envelope");
990        assert_eq!(decoded, envelope);
991        assert_eq!(
992            decoded.schema_version,
993            ATP_RUNTIME_EVIDENCE_DIAGNOSTIC_SCHEMA
994        );
995    }
996
997    #[test]
998    fn user_explanation_redacts_correlation_ids_and_replay_command() {
999        let envelope = sample_envelope();
1000        let doc = AtpRuntimeEvidenceBridge::explain_for_user(&envelope);
1001        assert!(doc.transfer_id.starts_with(REDACTED));
1002        assert_eq!(
1003            doc.evidence
1004                .replay
1005                .as_ref()
1006                .expect("replay pointer")
1007                .replay_command,
1008            REDACTED
1009        );
1010        assert!(
1011            doc.human_summary.len() < 400,
1012            "human explanation must stay concise"
1013        );
1014    }
1015
1016    #[test]
1017    fn incomplete_obligations_downgrade_to_advisory_risk() {
1018        let mut envelope = sample_envelope();
1019        envelope.obligation_counts.outstanding = 1;
1020        let doc = AtpRuntimeEvidenceBridge::explain(envelope);
1021        assert!(
1022            doc.advisory_risks
1023                .iter()
1024                .any(|risk| risk.contains("obligation accounting is incomplete"))
1025        );
1026        assert!(
1027            doc.proof_claims
1028                .iter()
1029                .all(|claim| !claim.contains("obligation accounting closed cleanly"))
1030        );
1031    }
1032
1033    #[test]
1034    fn network_truth_pressure_keeps_measurements_out_of_proof_claims() {
1035        let mut envelope = sample_envelope();
1036        let mut network_truth = AtpNetworkTruthPressureModel::new(42_000);
1037        network_truth.path_id = Some("path-local-correlation-123456".to_string());
1038        assert!(network_truth.add_signal(AtpNetworkTruthSignal::measured(
1039            AtpNetworkTruthMetric::Rtt,
1040            25_000,
1041            "micros",
1042            120_000,
1043            "pathlog-rtt-123456",
1044        )));
1045        assert!(network_truth.add_signal(AtpNetworkTruthSignal::inferred(
1046            AtpNetworkTruthMetric::Loss,
1047            22,
1048            "permille",
1049            720_000,
1050            "pathlog-loss-123456",
1051            "loss inferred from ACK gap and retransmit evidence",
1052        )));
1053        assert!(network_truth.add_signal(AtpNetworkTruthSignal::unsupported(
1054            AtpNetworkTruthMetric::CongestionWindow,
1055            "platform did not expose cwnd",
1056        )));
1057        envelope.network_truth = Some(network_truth);
1058
1059        let doc = AtpRuntimeEvidenceBridge::explain(envelope);
1060        assert!(
1061            doc.network_truth_explanations
1062                .iter()
1063                .any(|line| line.contains("network truth pressure degraded"))
1064        );
1065        assert!(
1066            doc.advisory_risks
1067                .iter()
1068                .any(|risk| risk.contains("network truth pressure is degraded"))
1069        );
1070        assert!(
1071            doc.proof_claims
1072                .iter()
1073                .all(|claim| !claim.contains("network truth")),
1074            "measured network facts must not become proof claims"
1075        );
1076        assert!(
1077            doc.unavailable_signals
1078                .iter()
1079                .any(|signal| signal == "network truth congestion_window unsupported")
1080        );
1081    }
1082
1083    #[test]
1084    fn network_truth_model_bounds_cardinality_and_redacts_user_view() {
1085        let mut model = AtpNetworkTruthPressureModel::new(7);
1086        model.path_id = Some("path-sensitive-abcdef".to_string());
1087        for idx in 0..ATP_NETWORK_TRUTH_MAX_SIGNALS {
1088            assert!(model.add_signal(AtpNetworkTruthSignal::advisory(
1089                AtpNetworkTruthMetric::RelayDirectDelta,
1090                i64::try_from(idx).expect("idx fits"),
1091                "ppm",
1092                250_000,
1093                format!("relay-delta-sensitive-{idx}"),
1094                "relay path id contained a local endpoint",
1095            )));
1096        }
1097        assert!(!model.add_signal(AtpNetworkTruthSignal::measured(
1098            AtpNetworkTruthMetric::Rtt,
1099            1,
1100            "micros",
1101            1,
1102            "overflow",
1103        )));
1104
1105        let redacted = model.redacted_for_user();
1106        assert_eq!(redacted.signals.len(), ATP_NETWORK_TRUTH_MAX_SIGNALS);
1107        assert!(
1108            redacted
1109                .path_id
1110                .as_deref()
1111                .unwrap_or_default()
1112                .starts_with(REDACTED)
1113        );
1114        assert!(
1115            redacted
1116                .signals
1117                .iter()
1118                .all(|signal| signal.detail.as_deref() == Some(REDACTED))
1119        );
1120    }
1121
1122    #[test]
1123    fn network_truth_pressure_hysteresis_prevents_noisy_downshift() {
1124        let mut model = AtpNetworkTruthPressureModel::new(99);
1125        assert!(model.add_signal(AtpNetworkTruthSignal::inferred(
1126            AtpNetworkTruthMetric::DiskLag,
1127            510_000,
1128            "ppm",
1129            510_000,
1130            "disk-lag-row",
1131            "disk write lag remained near degraded threshold",
1132        )));
1133        assert_eq!(model.pressure_level(), AtpNetworkPressureLevel::Watch);
1134        assert_eq!(
1135            model.pressure_level_with_hysteresis(Some(AtpNetworkPressureLevel::Degraded)),
1136            AtpNetworkPressureLevel::Degraded
1137        );
1138    }
1139}