Skip to main content

asupersync/
evidence.rs

1//! Spork Evidence Ledger Schema + Deterministic Rendering (bd-2dfoo)
2//!
3//! Every Spork decision — supervision, registry, link/monitor — produces a
4//! structured evidence record explaining *why* the decision was made and
5//! *which constraint was binding*.  This module defines the unified schema
6//! and a deterministic rendering format.
7//!
8//! # Design Principles
9//!
10//! 1. **Deterministic**: Evidence rendering is a pure function of the record.
11//!    Identical inputs always produce identical output (byte-for-byte).
12//! 2. **Test-assertable**: Records can be compared structurally, and rendered
13//!    output can be matched against expected strings in tests.
14//! 3. **Module-agnostic**: The `EvidenceRecord` envelope is the same regardless
15//!    of which Spork subsystem produced it; the `detail` field carries the
16//!    subsystem-specific constraint.
17//! 4. **Append-only**: Ledgers only grow.  Entries are never mutated or removed.
18//!
19//! # Schema Overview
20//!
21//! ```text
22//! EvidenceRecord
23//! ├── timestamp: u64 (virtual nanoseconds)
24//! ├── task_id: TaskId
25//! ├── region_id: RegionId
26//! ├── subsystem: Subsystem (Supervision | Registry | Link | Monitor)
27//! ├── detail: EvidenceDetail (enum over subsystem-specific constraints)
28//! └── verdict: Verdict (one-word outcome: Restart, Stop, Escalate, Accept, Reject, Propagate, …)
29//! ```
30
31use std::fmt;
32use std::time::Duration;
33
34use serde::{Deserialize, Serialize};
35
36use crate::types::{CancelReason, Outcome, RegionId, TaskId};
37
38// ---------------------------------------------------------------------------
39// Subsystem + Verdict enums
40// ---------------------------------------------------------------------------
41
42/// Spork subsystem that produced the evidence.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
44pub enum Subsystem {
45    /// Supervisor restart/stop/escalate decisions.
46    Supervision,
47    /// Registry name lease accept/reject/cleanup decisions.
48    Registry,
49    /// Link exit-signal propagation decisions.
50    Link,
51    /// Monitor down-notification delivery decisions.
52    Monitor,
53}
54
55impl fmt::Display for Subsystem {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::Supervision => write!(f, "supervision"),
59            Self::Registry => write!(f, "registry"),
60            Self::Link => write!(f, "link"),
61            Self::Monitor => write!(f, "monitor"),
62        }
63    }
64}
65
66/// One-word verdict summarizing the decision outcome.
67///
68/// The verdict is the "what happened" counterpart to the detail's "why".
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70pub enum Verdict {
71    // -- Supervision --
72    /// Actor will be restarted.
73    Restart,
74    /// Actor will be stopped permanently.
75    Stop,
76    /// Failure will be escalated to parent region.
77    Escalate,
78
79    // -- Registry --
80    /// Name registration accepted.
81    Accept,
82    /// Name registration rejected (collision, closed region, etc.).
83    Reject,
84    /// Name lease released (normal lifecycle).
85    Release,
86    /// Name lease aborted (cancellation, cleanup).
87    Abort,
88
89    // -- Link --
90    /// Exit signal propagated to linked task.
91    Propagate,
92    /// Exit signal suppressed (trap_exit, demonitor, etc.).
93    Suppress,
94
95    // -- Monitor --
96    /// Down notification delivered.
97    Deliver,
98    /// Down notification dropped (watcher already terminated, region cleaned up).
99    Drop,
100}
101
102impl fmt::Display for Verdict {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::Restart => write!(f, "RESTART"),
106            Self::Stop => write!(f, "STOP"),
107            Self::Escalate => write!(f, "ESCALATE"),
108            Self::Accept => write!(f, "ACCEPT"),
109            Self::Reject => write!(f, "REJECT"),
110            Self::Release => write!(f, "RELEASE"),
111            Self::Abort => write!(f, "ABORT"),
112            Self::Propagate => write!(f, "PROPAGATE"),
113            Self::Suppress => write!(f, "SUPPRESS"),
114            Self::Deliver => write!(f, "DELIVER"),
115            Self::Drop => write!(f, "DROP"),
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Evidence Detail (subsystem-specific constraint / reasoning)
122// ---------------------------------------------------------------------------
123
124/// Subsystem-specific evidence detail explaining *why* a decision was made.
125///
126/// Each variant carries the binding constraint: the specific rule, limit,
127/// or condition that determined the verdict.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub enum EvidenceDetail {
130    /// Supervision decision detail.
131    Supervision(SupervisionDetail),
132    /// Registry decision detail.
133    Registry(RegistryDetail),
134    /// Link decision detail.
135    Link(LinkDetail),
136    /// Monitor decision detail.
137    Monitor(MonitorDetail),
138}
139
140impl Eq for EvidenceDetail {}
141
142impl fmt::Display for EvidenceDetail {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            Self::Supervision(d) => write!(f, "{d}"),
146            Self::Registry(d) => write!(f, "{d}"),
147            Self::Link(d) => write!(f, "{d}"),
148            Self::Monitor(d) => write!(f, "{d}"),
149        }
150    }
151}
152
153// -- Supervision detail --
154
155/// Why a supervision decision was made.
156///
157/// Maps directly to the `BindingConstraint` enum in `src/supervision.rs`
158/// but expressed in the generalized evidence schema.
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub enum SupervisionDetail {
161    /// Outcome severity prevents restart (Panicked / Cancelled / Ok).
162    MonotoneSeverity {
163        /// The outcome kind label.
164        outcome_kind: String,
165    },
166    /// Strategy is explicitly `Stop`.
167    ExplicitStop,
168    /// Strategy is explicitly `Escalate`.
169    ExplicitEscalate,
170    /// Strategy is `Escalate`, but there is no parent supervisor to receive it.
171    EscalateWithoutParent,
172    /// Restart was allowed: window + budget checks passed.
173    RestartAllowed {
174        /// Which attempt (1-indexed).
175        attempt: u32,
176        /// Delay before restart (if any).
177        delay: Option<Duration>,
178    },
179    /// Sliding-window restart count exhausted.
180    WindowExhausted {
181        /// Maximum restarts in window.
182        max_restarts: u32,
183        /// Window duration.
184        window: Duration,
185    },
186    /// Budget constraint refused restart.
187    BudgetRefused {
188        /// Human-readable constraint description.
189        constraint: String,
190    },
191}
192
193impl Eq for SupervisionDetail {}
194
195impl fmt::Display for SupervisionDetail {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            Self::MonotoneSeverity { outcome_kind } => {
199                write!(f, "monotone severity: {outcome_kind} is not restartable")
200            }
201            Self::ExplicitStop => write!(f, "strategy is Stop"),
202            Self::ExplicitEscalate => write!(f, "strategy is Escalate"),
203            Self::EscalateWithoutParent => {
204                write!(f, "strategy is Escalate but no parent region exists")
205            }
206            Self::RestartAllowed { attempt, delay } => match delay {
207                Some(d) => write!(f, "restart allowed (attempt {attempt}, delay {d:?})"),
208                None => write!(f, "restart allowed (attempt {attempt})"),
209            },
210            Self::WindowExhausted {
211                max_restarts,
212                window,
213            } => write!(f, "window exhausted: {max_restarts} restarts in {window:?}"),
214            Self::BudgetRefused { constraint } => {
215                write!(f, "budget refused: {constraint}")
216            }
217        }
218    }
219}
220
221// -- Registry detail --
222
223/// Why a registry decision was made.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub enum RegistryDetail {
226    /// Name was available and registration succeeded.
227    NameAvailable,
228    /// Name was already held by another task (collision).
229    NameCollision {
230        /// The existing holder.
231        existing_holder: TaskId,
232    },
233    /// Region is closed; registration refused.
234    RegionClosed {
235        /// The closed region.
236        region: RegionId,
237    },
238    /// Name lease released by holder (obligation committed).
239    LeaseCommitted,
240    /// Name lease aborted due to cancellation.
241    LeaseCancelled {
242        /// Cancellation reason.
243        reason: CancelReason,
244    },
245    /// Name lease aborted due to region cleanup.
246    LeaseCleanedUp {
247        /// The region being cleaned up.
248        region: RegionId,
249    },
250    /// Name lease aborted due to task cleanup.
251    TaskCleanedUp {
252        /// The task being cleaned up.
253        task: TaskId,
254    },
255}
256
257impl fmt::Display for RegistryDetail {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            Self::NameAvailable => write!(f, "name available"),
261            Self::NameCollision { existing_holder } => {
262                write!(f, "name collision: held by {existing_holder:?}")
263            }
264            Self::RegionClosed { region } => {
265                write!(f, "region closed: {region:?}")
266            }
267            Self::LeaseCommitted => write!(f, "lease committed (normal release)"),
268            Self::LeaseCancelled { reason } => {
269                write!(f, "lease cancelled: {reason}")
270            }
271            Self::LeaseCleanedUp { region } => {
272                write!(f, "lease cleaned up (region {region:?} closing)")
273            }
274            Self::TaskCleanedUp { task } => {
275                write!(f, "lease cleaned up (task {task:?} terminating)")
276            }
277        }
278    }
279}
280
281// -- Link detail --
282
283/// Why a link decision was made.
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub enum LinkDetail {
286    /// Linked task failed; exit signal propagated.
287    ExitPropagated {
288        /// The source of the failure.
289        source: TaskId,
290        /// The failure outcome.
291        reason: Outcome<(), ()>,
292    },
293    /// Exit signal suppressed because target is trapping exits.
294    TrapExit {
295        /// The source of the failure.
296        source: TaskId,
297    },
298    /// Link removed before failure occurred (no propagation).
299    Unlinked,
300    /// Link cleaned up due to region closure.
301    RegionCleanup {
302        /// The region being closed.
303        region: RegionId,
304    },
305}
306
307impl Eq for LinkDetail {}
308
309impl fmt::Display for LinkDetail {
310    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311        match self {
312            Self::ExitPropagated { source, reason } => {
313                write!(f, "exit propagated from {source:?} ({reason:?})")
314            }
315            Self::TrapExit { source } => {
316                write!(f, "exit trapped from {source:?}")
317            }
318            Self::Unlinked => write!(f, "unlinked before failure"),
319            Self::RegionCleanup { region } => {
320                write!(f, "link cleaned up (region {region:?} closing)")
321            }
322        }
323    }
324}
325
326// -- Monitor detail --
327
328/// Why a monitor decision was made.
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub enum MonitorDetail {
331    /// Down notification delivered to watcher.
332    DownDelivered {
333        /// The terminated task.
334        monitored: TaskId,
335        /// The termination outcome.
336        reason: Outcome<(), ()>,
337    },
338    /// Down notification dropped because watcher region was cleaned up.
339    WatcherRegionClosed {
340        /// The watcher's region.
341        region: RegionId,
342    },
343    /// Monitor removed before task terminated.
344    Demonitored,
345    /// Monitor cleaned up due to region closure.
346    RegionCleanup {
347        /// The region being closed.
348        region: RegionId,
349        /// Number of monitors released.
350        count: usize,
351    },
352}
353
354impl Eq for MonitorDetail {}
355
356impl fmt::Display for MonitorDetail {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        match self {
359            Self::DownDelivered { monitored, reason } => {
360                write!(f, "down delivered for {monitored:?} ({reason:?})")
361            }
362            Self::WatcherRegionClosed { region } => {
363                write!(f, "watcher region {region:?} closed")
364            }
365            Self::Demonitored => write!(f, "demonitored before termination"),
366            Self::RegionCleanup { region, count } => {
367                write!(f, "region {region:?} cleanup released {count} monitor(s)")
368            }
369        }
370    }
371}
372
373// ---------------------------------------------------------------------------
374// Evidence Record
375// ---------------------------------------------------------------------------
376
377/// A single evidence record capturing why a Spork decision was made.
378///
379/// This is the generalized, subsystem-agnostic envelope.  Every Spork
380/// subsystem produces `EvidenceRecord` entries with identical metadata
381/// layout and subsystem-specific `detail`.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct EvidenceRecord {
384    /// Virtual timestamp (nanoseconds) when the decision was made.
385    pub timestamp: u64,
386    /// The task involved in the decision.
387    pub task_id: TaskId,
388    /// The region containing the task.
389    pub region_id: RegionId,
390    /// Which Spork subsystem produced this evidence.
391    pub subsystem: Subsystem,
392    /// One-word verdict: what happened.
393    pub verdict: Verdict,
394    /// Subsystem-specific detail: why it happened.
395    pub detail: EvidenceDetail,
396}
397
398impl EvidenceRecord {
399    /// Render this record to a deterministic, single-line string.
400    ///
401    /// Format: `[{timestamp_ns}] {subsystem} {verdict}: {detail}`
402    ///
403    /// This format is stable and test-assertable.
404    #[must_use]
405    pub fn render(&self) -> String {
406        format!(
407            "[{}] {} {}: {}",
408            self.timestamp, self.subsystem, self.verdict, self.detail
409        )
410    }
411
412    /// Convert this record into an evidence "card" suitable for deterministic,
413    /// human/agent-friendly debugging.
414    ///
415    /// A card is the "galaxy-brain" triple:
416    /// - `rule`: a general rule or equation form ("why this verdict follows")
417    /// - `substitution`: the same rule with concrete values
418    /// - `intuition`: a one-line explanation
419    #[must_use]
420    pub fn to_card(&self) -> EvidenceCard {
421        let (rule, substitution, intuition) =
422            evidence_card_triple(self.subsystem, self.verdict, &self.detail);
423
424        EvidenceCard {
425            timestamp: self.timestamp,
426            task_id: self.task_id,
427            region_id: self.region_id,
428            subsystem: self.subsystem,
429            verdict: self.verdict,
430            rule,
431            substitution,
432            intuition,
433        }
434    }
435
436    /// Render this record as a deterministic, multi-line evidence card.
437    #[must_use]
438    pub fn render_card(&self) -> String {
439        self.to_card().render()
440    }
441}
442
443impl fmt::Display for EvidenceRecord {
444    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
445        write!(
446            f,
447            "[{}] {} {}: {}",
448            self.timestamp, self.subsystem, self.verdict, self.detail
449        )
450    }
451}
452
453// ---------------------------------------------------------------------------
454// Generalized Evidence Ledger
455// ---------------------------------------------------------------------------
456
457// ---------------------------------------------------------------------------
458// Evidence Cards (Galaxy-Brain Rendering)
459// ---------------------------------------------------------------------------
460
461/// A deterministic "galaxy-brain" evidence card derived from a single record.
462///
463/// This is intentionally lightweight and stable so tests can assert exact
464/// output and agents can grep for `rule:` / `substitution:` / `intuition:`.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466pub struct EvidenceCard {
467    /// Virtual timestamp (nanoseconds) when the decision was made.
468    pub timestamp: u64,
469    /// The task involved in the decision.
470    pub task_id: TaskId,
471    /// The region containing the task.
472    pub region_id: RegionId,
473    /// Which Spork subsystem produced this evidence.
474    pub subsystem: Subsystem,
475    /// One-word verdict: what happened.
476    pub verdict: Verdict,
477    /// General rule/equation form.
478    pub rule: String,
479    /// Rule with concrete values substituted.
480    pub substitution: String,
481    /// One-line intuition.
482    pub intuition: String,
483}
484
485impl EvidenceCard {
486    /// Render this card to a deterministic, multi-line string.
487    ///
488    /// Format:
489    ///
490    /// ```text
491    /// [{timestamp}] {subsystem} {verdict} task={task:?} region={region:?}
492    /// rule: ...
493    /// substitution: ...
494    /// intuition: ...
495    /// ```
496    #[must_use]
497    pub fn render(&self) -> String {
498        format!(
499            "[{}] {} {} task={:?} region={:?}\nrule: {}\nsubstitution: {}\nintuition: {}\n",
500            self.timestamp,
501            self.subsystem,
502            self.verdict,
503            self.task_id,
504            self.region_id,
505            self.rule,
506            self.substitution,
507            self.intuition
508        )
509    }
510}
511
512impl fmt::Display for EvidenceCard {
513    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
514        f.write_str(&self.render())
515    }
516}
517
518fn supervision_card_triple(detail: &SupervisionDetail) -> (String, String, String) {
519    match detail {
520        SupervisionDetail::MonotoneSeverity { outcome_kind } => (
521            "If outcome severity is not restartable, the supervisor must STOP.".to_string(),
522            format!("outcome_kind={outcome_kind} => STOP"),
523            format!(
524                "Outcome {outcome_kind} is terminal for supervision; stopping preserves monotone severity."
525            ),
526        ),
527        SupervisionDetail::ExplicitStop => (
528            "If supervision strategy is Stop, the supervisor must STOP.".to_string(),
529            "strategy=Stop => STOP".to_string(),
530            "Strategy is Stop; no restart is attempted.".to_string(),
531        ),
532        SupervisionDetail::ExplicitEscalate => (
533            "If supervision strategy is Escalate, the supervisor must ESCALATE.".to_string(),
534            "strategy=Escalate => ESCALATE".to_string(),
535            "Strategy is Escalate; failure is propagated to the parent region.".to_string(),
536        ),
537        SupervisionDetail::EscalateWithoutParent => (
538            "If supervision strategy is Escalate but no parent region exists, the supervisor must STOP."
539                .to_string(),
540            "strategy=Escalate,parent=None => STOP".to_string(),
541            "Root escalation has no parent target; stopping preserves a total supervision decision."
542                .to_string(),
543        ),
544        SupervisionDetail::RestartAllowed { attempt, delay } => (
545            "If restart window and budget allow, the supervisor may RESTART.".to_string(),
546            delay.as_ref().map_or_else(
547                || format!("attempt={attempt} => RESTART"),
548                |d| format!("attempt={attempt}, delay={d:?} => RESTART"),
549            ),
550            "Restart attempt is permitted; any configured delay is applied deterministically."
551                .to_string(),
552        ),
553        SupervisionDetail::WindowExhausted {
554            max_restarts,
555            window,
556        } => (
557            "If restarts in the intensity window exceed the limit, the supervisor must STOP."
558                .to_string(),
559            format!("max_restarts={max_restarts}, window={window:?} => STOP"),
560            "Restart intensity exceeded; stopping prevents an unbounded crash loop.".to_string(),
561        ),
562        SupervisionDetail::BudgetRefused { constraint } => (
563            "If restart would violate budget constraints, the supervisor must STOP.".to_string(),
564            format!("{constraint} => STOP"),
565            "Budget would be exceeded; stopping is deterministic and cancel-correct.".to_string(),
566        ),
567    }
568}
569
570fn registry_card_triple(detail: &RegistryDetail) -> (String, String, String) {
571    match detail {
572        RegistryDetail::NameAvailable => (
573            "If the name is unheld, registration is ACCEPTED.".to_string(),
574            "name is available => ACCEPT".to_string(),
575            "No collision; a name lease is created and must be resolved linearly.".to_string(),
576        ),
577        RegistryDetail::NameCollision { existing_holder } => (
578            "If the name is already held, registration is REJECTED.".to_string(),
579            format!("existing_holder={existing_holder:?} => REJECT"),
580            "Collision prevents ambiguous ownership; reject to preserve determinism.".to_string(),
581        ),
582        RegistryDetail::RegionClosed { region } => (
583            "If the owning region is closed, registration is REJECTED.".to_string(),
584            format!("region={region:?} is closed => REJECT"),
585            "Closed regions cannot accept new obligations; reject avoids orphaned leases."
586                .to_string(),
587        ),
588        RegistryDetail::LeaseCommitted => (
589            "If the lease obligation is committed, the name is RELEASED.".to_string(),
590            "lease committed => RELEASE".to_string(),
591            "Normal lifecycle release; name becomes available again.".to_string(),
592        ),
593        RegistryDetail::LeaseCancelled { reason } => (
594            "If cancellation occurs, the lease is ABORTED.".to_string(),
595            format!("cancel_reason={reason} => ABORT"),
596            "Cancellation triggers cleanup; abort avoids stale names.".to_string(),
597        ),
598        RegistryDetail::LeaseCleanedUp { region } => (
599            "If region cleanup runs, the lease is ABORTED.".to_string(),
600            format!("cleanup_region={region:?} => ABORT"),
601            "Region close implies quiescence; leases are aborted during cleanup.".to_string(),
602        ),
603        RegistryDetail::TaskCleanedUp { task } => (
604            "If task cleanup runs, the lease is ABORTED.".to_string(),
605            format!("cleanup_task={task:?} => ABORT"),
606            "Task termination must not leave names held; abort releases the lease.".to_string(),
607        ),
608    }
609}
610
611fn link_card_triple(detail: &LinkDetail) -> (String, String, String) {
612    match detail {
613        LinkDetail::ExitPropagated { source, reason } => (
614            "If a linked task exits and exits are not trapped, the signal is PROPAGATED."
615                .to_string(),
616            format!("source={source:?}, reason={reason:?} => PROPAGATE"),
617            "Linked failures propagate to preserve OTP-style failure semantics.".to_string(),
618        ),
619        LinkDetail::TrapExit { source } => (
620            "If the target traps exits, the signal is SUPPRESSED.".to_string(),
621            format!("source={source:?}, trap_exit=true => SUPPRESS"),
622            "Target traps exits, so failure is converted into a message instead of killing."
623                .to_string(),
624        ),
625        LinkDetail::Unlinked => (
626            "If the link was removed, no propagation occurs.".to_string(),
627            "link already removed => SUPPRESS".to_string(),
628            "No active link exists; nothing to propagate.".to_string(),
629        ),
630        LinkDetail::RegionCleanup { region } => (
631            "If region cleanup runs, links are cleaned up without propagation.".to_string(),
632            format!("cleanup_region={region:?} => SUPPRESS"),
633            "Region close implies quiescence; cleanup suppresses further signals.".to_string(),
634        ),
635    }
636}
637
638fn monitor_card_triple(detail: &MonitorDetail) -> (String, String, String) {
639    match detail {
640        MonitorDetail::DownDelivered { monitored, reason } => (
641            "If a monitored task terminates, a DOWN is DELIVERED to the watcher.".to_string(),
642            format!("monitored={monitored:?}, reason={reason:?} => DELIVER"),
643            "Monitors provide observation without coupling; DOWN is delivered deterministically."
644                .to_string(),
645        ),
646        MonitorDetail::WatcherRegionClosed { region } => (
647            "If the watcher region is closed, DOWN delivery is DROPPED.".to_string(),
648            format!("watcher_region={region:?} closed => DROP"),
649            "Watcher cannot receive messages after cleanup; drop avoids resurrecting work."
650                .to_string(),
651        ),
652        MonitorDetail::Demonitored => (
653            "If the monitor was removed, no DOWN is delivered.".to_string(),
654            "demonitored => DROP".to_string(),
655            "No active monitor exists; nothing to deliver.".to_string(),
656        ),
657        MonitorDetail::RegionCleanup { region, count } => (
658            "If region cleanup runs, monitors are DROPPED.".to_string(),
659            format!("cleanup_region={region:?}, released={count} => DROP"),
660            "Region close releases monitor obligations; dropping is deterministic cleanup."
661                .to_string(),
662        ),
663    }
664}
665
666fn evidence_card_triple(
667    _subsystem: Subsystem,
668    _verdict: Verdict,
669    detail: &EvidenceDetail,
670) -> (String, String, String) {
671    match detail {
672        EvidenceDetail::Supervision(d) => supervision_card_triple(d),
673        EvidenceDetail::Registry(d) => registry_card_triple(d),
674        EvidenceDetail::Link(d) => link_card_triple(d),
675        EvidenceDetail::Monitor(d) => monitor_card_triple(d),
676    }
677}
678
679/// Deterministic, append-only, subsystem-agnostic evidence ledger.
680///
681/// Collects [`EvidenceRecord`] entries from any Spork subsystem.
682/// Supports filtering by subsystem, verdict, task, or arbitrary predicate.
683///
684/// # Determinism
685///
686/// Entry order is insertion order, which is deterministic under virtual time.
687/// The [`render`](Self::render) method produces a stable multi-line string.
688#[derive(Debug, Clone, Default, Serialize, Deserialize)]
689pub struct GeneralizedLedger {
690    entries: Vec<EvidenceRecord>,
691}
692
693impl GeneralizedLedger {
694    /// Create an empty ledger.
695    #[must_use]
696    pub fn new() -> Self {
697        Self {
698            entries: Vec::new(),
699        }
700    }
701
702    /// Append an evidence record.
703    pub fn push(&mut self, record: EvidenceRecord) {
704        self.entries.push(record);
705    }
706
707    /// All recorded entries, in insertion order.
708    #[must_use]
709    pub fn entries(&self) -> &[EvidenceRecord] {
710        &self.entries
711    }
712
713    /// Number of recorded entries.
714    #[must_use]
715    pub fn len(&self) -> usize {
716        self.entries.len()
717    }
718
719    /// Returns `true` if no entries have been recorded.
720    #[must_use]
721    pub fn is_empty(&self) -> bool {
722        self.entries.is_empty()
723    }
724
725    /// Iterate over entries for a specific task.
726    pub fn for_task(&self, task_id: TaskId) -> impl Iterator<Item = &EvidenceRecord> {
727        self.entries.iter().filter(move |e| e.task_id == task_id)
728    }
729
730    /// Iterate over entries from a specific subsystem.
731    pub fn for_subsystem(&self, subsystem: Subsystem) -> impl Iterator<Item = &EvidenceRecord> {
732        self.entries
733            .iter()
734            .filter(move |e| e.subsystem == subsystem)
735    }
736
737    /// Iterate over entries with a specific verdict.
738    pub fn with_verdict(&self, verdict: Verdict) -> impl Iterator<Item = &EvidenceRecord> {
739        self.entries.iter().filter(move |e| e.verdict == verdict)
740    }
741
742    /// Iterate over entries matching an arbitrary predicate.
743    pub fn filter<F>(&self, predicate: F) -> impl Iterator<Item = &EvidenceRecord>
744    where
745        F: Fn(&EvidenceRecord) -> bool,
746    {
747        self.entries.iter().filter(move |e| predicate(e))
748    }
749
750    /// Clear all entries.
751    pub fn clear(&mut self) {
752        self.entries.clear();
753    }
754
755    /// Render the entire ledger to a deterministic, multi-line string.
756    ///
757    /// Each entry is rendered on its own line using [`EvidenceRecord::render`].
758    /// The output is stable and test-assertable.
759    #[must_use]
760    pub fn render(&self) -> String {
761        let mut out = String::new();
762        for entry in &self.entries {
763            out.push_str(&entry.render());
764            out.push('\n');
765        }
766        out
767    }
768
769    /// Render the entire ledger to deterministic evidence cards.
770    ///
771    /// Each entry becomes one card, separated by a blank line.
772    #[must_use]
773    pub fn render_cards(&self) -> String {
774        let mut out = String::new();
775        for entry in &self.entries {
776            out.push_str(&entry.render_card());
777            out.push('\n');
778        }
779        out
780    }
781}
782
783impl fmt::Display for GeneralizedLedger {
784    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
785        for entry in &self.entries {
786            writeln!(f, "{entry}")?;
787        }
788        Ok(())
789    }
790}
791
792// ---------------------------------------------------------------------------
793// Tests
794// ---------------------------------------------------------------------------
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use crate::types::PanicPayload;
800    use crate::util::ArenaIndex;
801
802    fn test_task_id() -> TaskId {
803        TaskId::from_arena(ArenaIndex::new(0, 1))
804    }
805
806    fn test_task_id_2() -> TaskId {
807        TaskId::from_arena(ArenaIndex::new(0, 2))
808    }
809
810    fn test_region_id() -> RegionId {
811        RegionId::from_arena(ArenaIndex::new(0, 0))
812    }
813
814    fn init_test(name: &str) {
815        crate::test_utils::init_test_logging();
816        crate::test_phase!(name);
817    }
818
819    #[test]
820    fn evidence_record_render_supervision_restart() {
821        init_test("evidence_record_render_supervision_restart");
822
823        let record = EvidenceRecord {
824            timestamp: 1_000_000_000,
825            task_id: test_task_id(),
826            region_id: test_region_id(),
827            subsystem: Subsystem::Supervision,
828            verdict: Verdict::Restart,
829            detail: EvidenceDetail::Supervision(SupervisionDetail::RestartAllowed {
830                attempt: 2,
831                delay: Some(Duration::from_millis(200)),
832            }),
833        };
834
835        let rendered = record.render();
836        assert!(rendered.contains("supervision RESTART"));
837        assert!(rendered.contains("restart allowed (attempt 2, delay 200ms)"));
838
839        crate::test_complete!("evidence_record_render_supervision_restart");
840    }
841
842    #[test]
843    fn evidence_record_render_supervision_stop() {
844        init_test("evidence_record_render_supervision_stop");
845
846        let record = EvidenceRecord {
847            timestamp: 2_000_000_000,
848            task_id: test_task_id(),
849            region_id: test_region_id(),
850            subsystem: Subsystem::Supervision,
851            verdict: Verdict::Stop,
852            detail: EvidenceDetail::Supervision(SupervisionDetail::WindowExhausted {
853                max_restarts: 3,
854                window: Duration::from_secs(60),
855            }),
856        };
857
858        let rendered = record.render();
859        assert!(rendered.contains("supervision STOP"));
860        assert!(rendered.contains("window exhausted: 3 restarts in 60s"));
861
862        crate::test_complete!("evidence_record_render_supervision_stop");
863    }
864
865    #[test]
866    fn evidence_record_render_registry_accept() {
867        init_test("evidence_record_render_registry_accept");
868
869        let record = EvidenceRecord {
870            timestamp: 500,
871            task_id: test_task_id(),
872            region_id: test_region_id(),
873            subsystem: Subsystem::Registry,
874            verdict: Verdict::Accept,
875            detail: EvidenceDetail::Registry(RegistryDetail::NameAvailable),
876        };
877
878        assert_eq!(record.render(), "[500] registry ACCEPT: name available");
879
880        crate::test_complete!("evidence_record_render_registry_accept");
881    }
882
883    #[test]
884    fn evidence_record_render_registry_reject_collision() {
885        init_test("evidence_record_render_registry_reject_collision");
886
887        let record = EvidenceRecord {
888            timestamp: 600,
889            task_id: test_task_id(),
890            region_id: test_region_id(),
891            subsystem: Subsystem::Registry,
892            verdict: Verdict::Reject,
893            detail: EvidenceDetail::Registry(RegistryDetail::NameCollision {
894                existing_holder: test_task_id_2(),
895            }),
896        };
897
898        let rendered = record.render();
899        assert!(rendered.contains("registry REJECT"));
900        assert!(rendered.contains("name collision"));
901
902        crate::test_complete!("evidence_record_render_registry_reject_collision");
903    }
904
905    #[test]
906    fn evidence_record_render_link_propagate() {
907        init_test("evidence_record_render_link_propagate");
908
909        let record = EvidenceRecord {
910            timestamp: 700,
911            task_id: test_task_id(),
912            region_id: test_region_id(),
913            subsystem: Subsystem::Link,
914            verdict: Verdict::Propagate,
915            detail: EvidenceDetail::Link(LinkDetail::ExitPropagated {
916                source: test_task_id_2(),
917                reason: Outcome::Err(()),
918            }),
919        };
920
921        let rendered = record.render();
922        assert!(rendered.contains("link PROPAGATE"));
923        assert!(rendered.contains("exit propagated"));
924
925        crate::test_complete!("evidence_record_render_link_propagate");
926    }
927
928    #[test]
929    fn evidence_record_render_monitor_deliver() {
930        init_test("evidence_record_render_monitor_deliver");
931
932        let record = EvidenceRecord {
933            timestamp: 800,
934            task_id: test_task_id(),
935            region_id: test_region_id(),
936            subsystem: Subsystem::Monitor,
937            verdict: Verdict::Deliver,
938            detail: EvidenceDetail::Monitor(MonitorDetail::DownDelivered {
939                monitored: test_task_id_2(),
940                reason: Outcome::Panicked(PanicPayload::new("oops")),
941            }),
942        };
943
944        let rendered = record.render();
945        assert!(rendered.contains("monitor DELIVER"));
946        assert!(rendered.contains("down delivered"));
947
948        crate::test_complete!("evidence_record_render_monitor_deliver");
949    }
950
951    #[test]
952    fn generalized_ledger_push_and_query() {
953        init_test("generalized_ledger_push_and_query");
954
955        let mut ledger = GeneralizedLedger::new();
956        assert!(ledger.is_empty());
957
958        // Add supervision entry
959        ledger.push(EvidenceRecord {
960            timestamp: 100,
961            task_id: test_task_id(),
962            region_id: test_region_id(),
963            subsystem: Subsystem::Supervision,
964            verdict: Verdict::Restart,
965            detail: EvidenceDetail::Supervision(SupervisionDetail::RestartAllowed {
966                attempt: 1,
967                delay: None,
968            }),
969        });
970
971        // Add registry entry
972        ledger.push(EvidenceRecord {
973            timestamp: 200,
974            task_id: test_task_id_2(),
975            region_id: test_region_id(),
976            subsystem: Subsystem::Registry,
977            verdict: Verdict::Accept,
978            detail: EvidenceDetail::Registry(RegistryDetail::NameAvailable),
979        });
980
981        // Add supervision stop
982        ledger.push(EvidenceRecord {
983            timestamp: 300,
984            task_id: test_task_id(),
985            region_id: test_region_id(),
986            subsystem: Subsystem::Supervision,
987            verdict: Verdict::Stop,
988            detail: EvidenceDetail::Supervision(SupervisionDetail::ExplicitStop),
989        });
990
991        assert_eq!(ledger.len(), 3);
992
993        // Filter by subsystem
994        assert_eq!(ledger.for_subsystem(Subsystem::Supervision).count(), 2);
995
996        assert_eq!(ledger.for_subsystem(Subsystem::Registry).count(), 1);
997
998        // Filter by verdict
999        assert_eq!(ledger.with_verdict(Verdict::Restart).count(), 1);
1000
1001        assert_eq!(ledger.with_verdict(Verdict::Stop).count(), 1);
1002
1003        // Filter by task
1004        assert_eq!(ledger.for_task(test_task_id()).count(), 2);
1005
1006        assert_eq!(ledger.for_task(test_task_id_2()).count(), 1);
1007
1008        crate::test_complete!("generalized_ledger_push_and_query");
1009    }
1010
1011    #[test]
1012    fn generalized_ledger_render_deterministic() {
1013        init_test("generalized_ledger_render_deterministic");
1014
1015        let mut ledger_a = GeneralizedLedger::new();
1016        let mut ledger_b = GeneralizedLedger::new();
1017
1018        let records = vec![
1019            EvidenceRecord {
1020                timestamp: 100,
1021                task_id: test_task_id(),
1022                region_id: test_region_id(),
1023                subsystem: Subsystem::Supervision,
1024                verdict: Verdict::Restart,
1025                detail: EvidenceDetail::Supervision(SupervisionDetail::RestartAllowed {
1026                    attempt: 1,
1027                    delay: None,
1028                }),
1029            },
1030            EvidenceRecord {
1031                timestamp: 200,
1032                task_id: test_task_id(),
1033                region_id: test_region_id(),
1034                subsystem: Subsystem::Supervision,
1035                verdict: Verdict::Stop,
1036                detail: EvidenceDetail::Supervision(SupervisionDetail::MonotoneSeverity {
1037                    outcome_kind: "Panicked".to_string(),
1038                }),
1039            },
1040        ];
1041
1042        for r in &records {
1043            ledger_a.push(r.clone());
1044            ledger_b.push(r.clone());
1045        }
1046
1047        // Byte-for-byte identical rendering
1048        assert_eq!(ledger_a.render(), ledger_b.render());
1049
1050        // Display matches render
1051        assert_eq!(format!("{ledger_a}"), ledger_a.render());
1052
1053        crate::test_complete!("generalized_ledger_render_deterministic");
1054    }
1055
1056    #[test]
1057    fn generalized_ledger_clear() {
1058        init_test("generalized_ledger_clear");
1059
1060        let mut ledger = GeneralizedLedger::new();
1061        ledger.push(EvidenceRecord {
1062            timestamp: 100,
1063            task_id: test_task_id(),
1064            region_id: test_region_id(),
1065            subsystem: Subsystem::Supervision,
1066            verdict: Verdict::Stop,
1067            detail: EvidenceDetail::Supervision(SupervisionDetail::ExplicitStop),
1068        });
1069
1070        assert_eq!(ledger.len(), 1);
1071        ledger.clear();
1072        assert!(ledger.is_empty());
1073
1074        crate::test_complete!("generalized_ledger_clear");
1075    }
1076
1077    #[test]
1078    fn subsystem_display() {
1079        init_test("subsystem_display");
1080
1081        assert_eq!(format!("{}", Subsystem::Supervision), "supervision");
1082        assert_eq!(format!("{}", Subsystem::Registry), "registry");
1083        assert_eq!(format!("{}", Subsystem::Link), "link");
1084        assert_eq!(format!("{}", Subsystem::Monitor), "monitor");
1085
1086        crate::test_complete!("subsystem_display");
1087    }
1088
1089    #[test]
1090    fn verdict_display() {
1091        init_test("verdict_display");
1092
1093        assert_eq!(format!("{}", Verdict::Restart), "RESTART");
1094        assert_eq!(format!("{}", Verdict::Stop), "STOP");
1095        assert_eq!(format!("{}", Verdict::Accept), "ACCEPT");
1096        assert_eq!(format!("{}", Verdict::Reject), "REJECT");
1097        assert_eq!(format!("{}", Verdict::Propagate), "PROPAGATE");
1098        assert_eq!(format!("{}", Verdict::Deliver), "DELIVER");
1099
1100        crate::test_complete!("verdict_display");
1101    }
1102
1103    #[test]
1104    fn registry_detail_display_variants() {
1105        init_test("registry_detail_display_variants");
1106
1107        let details = vec![
1108            (RegistryDetail::NameAvailable, "name available"),
1109            (
1110                RegistryDetail::LeaseCommitted,
1111                "lease committed (normal release)",
1112            ),
1113        ];
1114
1115        for (detail, expected) in details {
1116            assert_eq!(format!("{detail}"), expected);
1117        }
1118
1119        crate::test_complete!("registry_detail_display_variants");
1120    }
1121
1122    #[test]
1123    fn link_detail_display_variants() {
1124        init_test("link_detail_display_variants");
1125
1126        assert_eq!(
1127            format!("{}", LinkDetail::Unlinked),
1128            "unlinked before failure"
1129        );
1130
1131        crate::test_complete!("link_detail_display_variants");
1132    }
1133
1134    #[test]
1135    fn monitor_detail_display_variants() {
1136        init_test("monitor_detail_display_variants");
1137
1138        assert_eq!(
1139            format!("{}", MonitorDetail::Demonitored),
1140            "demonitored before termination"
1141        );
1142
1143        crate::test_complete!("monitor_detail_display_variants");
1144    }
1145
1146    #[test]
1147    fn generalized_ledger_filter_predicate() {
1148        init_test("generalized_ledger_filter_predicate");
1149
1150        let mut ledger = GeneralizedLedger::new();
1151        for i in 0u64..5 {
1152            ledger.push(EvidenceRecord {
1153                timestamp: i * 100,
1154                task_id: test_task_id(),
1155                region_id: test_region_id(),
1156                subsystem: Subsystem::Supervision,
1157                verdict: if i < 3 {
1158                    Verdict::Restart
1159                } else {
1160                    Verdict::Stop
1161                },
1162                detail: EvidenceDetail::Supervision(if i < 3 {
1163                    SupervisionDetail::RestartAllowed {
1164                        attempt: (i as u32) + 1,
1165                        delay: None,
1166                    }
1167                } else {
1168                    SupervisionDetail::WindowExhausted {
1169                        max_restarts: 3,
1170                        window: Duration::from_secs(60),
1171                    }
1172                }),
1173            });
1174        }
1175
1176        // Custom filter: entries after timestamp 200
1177        assert_eq!(ledger.filter(|e| e.timestamp > 200).count(), 2);
1178
1179        crate::test_complete!("generalized_ledger_filter_predicate");
1180    }
1181
1182    #[test]
1183    fn evidence_record_render_card_supervision_restart() {
1184        init_test("evidence_record_render_card_supervision_restart");
1185
1186        let record = EvidenceRecord {
1187            timestamp: 1_000_000_001,
1188            task_id: test_task_id(),
1189            region_id: test_region_id(),
1190            subsystem: Subsystem::Supervision,
1191            verdict: Verdict::Restart,
1192            detail: EvidenceDetail::Supervision(SupervisionDetail::RestartAllowed {
1193                attempt: 1,
1194                delay: Some(Duration::from_millis(10)),
1195            }),
1196        };
1197
1198        let rendered = record.render_card();
1199        assert!(rendered.contains("supervision RESTART"));
1200        assert!(rendered.contains("rule: If restart window and budget allow"));
1201        assert!(rendered.contains("substitution: attempt=1, delay=10ms => RESTART"));
1202        assert!(rendered.contains("intuition: Restart attempt is permitted"));
1203
1204        crate::test_complete!("evidence_record_render_card_supervision_restart");
1205    }
1206
1207    #[test]
1208    fn generalized_ledger_render_cards_deterministic() {
1209        init_test("generalized_ledger_render_cards_deterministic");
1210
1211        let mut ledger_a = GeneralizedLedger::new();
1212        let mut ledger_b = GeneralizedLedger::new();
1213
1214        for ledger in [&mut ledger_a, &mut ledger_b] {
1215            ledger.push(EvidenceRecord {
1216                timestamp: 10,
1217                task_id: test_task_id(),
1218                region_id: test_region_id(),
1219                subsystem: Subsystem::Registry,
1220                verdict: Verdict::Reject,
1221                detail: EvidenceDetail::Registry(RegistryDetail::NameCollision {
1222                    existing_holder: test_task_id_2(),
1223                }),
1224            });
1225            ledger.push(EvidenceRecord {
1226                timestamp: 11,
1227                task_id: test_task_id(),
1228                region_id: test_region_id(),
1229                subsystem: Subsystem::Supervision,
1230                verdict: Verdict::Stop,
1231                detail: EvidenceDetail::Supervision(SupervisionDetail::WindowExhausted {
1232                    max_restarts: 2,
1233                    window: Duration::from_secs(1),
1234                }),
1235            });
1236        }
1237
1238        // Byte-for-byte identical rendering
1239        assert_eq!(ledger_a.render_cards(), ledger_b.render_cards());
1240
1241        crate::test_complete!("generalized_ledger_render_cards_deterministic");
1242    }
1243
1244    // Pure data-type tests (wave 35 – CyanBarn)
1245
1246    #[test]
1247    fn subsystem_debug_copy_hash() {
1248        use std::collections::HashSet;
1249        let s = Subsystem::Supervision;
1250        let dbg = format!("{s:?}");
1251        assert!(dbg.contains("Supervision"));
1252
1253        // Copy
1254        let s2 = s;
1255        assert_eq!(s, s2);
1256
1257        // Hash consistency
1258        let mut set = HashSet::new();
1259        set.insert(Subsystem::Supervision);
1260        set.insert(Subsystem::Registry);
1261        set.insert(Subsystem::Link);
1262        set.insert(Subsystem::Monitor);
1263        assert_eq!(set.len(), 4);
1264        assert!(set.contains(&Subsystem::Link));
1265    }
1266
1267    #[test]
1268    fn verdict_debug_copy_hash() {
1269        use std::collections::HashSet;
1270        let v = Verdict::Restart;
1271        let dbg = format!("{v:?}");
1272        assert!(dbg.contains("Restart"));
1273
1274        // Copy
1275        let v2 = v;
1276        assert_eq!(v, v2);
1277
1278        // Hash - all 11 variants distinct
1279        let mut set = HashSet::new();
1280        for v in [
1281            Verdict::Restart,
1282            Verdict::Stop,
1283            Verdict::Escalate,
1284            Verdict::Accept,
1285            Verdict::Reject,
1286            Verdict::Release,
1287            Verdict::Abort,
1288            Verdict::Propagate,
1289            Verdict::Suppress,
1290            Verdict::Deliver,
1291            Verdict::Drop,
1292        ] {
1293            set.insert(v);
1294        }
1295        assert_eq!(set.len(), 11);
1296    }
1297
1298    #[test]
1299    fn evidence_detail_debug_clone_eq() {
1300        let detail = EvidenceDetail::Supervision(SupervisionDetail::ExplicitStop);
1301        let dbg = format!("{detail:?}");
1302        assert!(dbg.contains("Supervision"));
1303        assert!(dbg.contains("ExplicitStop"));
1304
1305        let cloned = detail.clone();
1306        assert_eq!(detail, cloned);
1307
1308        // Different variants are not equal
1309        let other = EvidenceDetail::Registry(RegistryDetail::NameAvailable);
1310        assert_ne!(detail, other);
1311    }
1312
1313    #[test]
1314    fn supervision_detail_debug_clone() {
1315        let detail = SupervisionDetail::RestartAllowed {
1316            attempt: 3,
1317            delay: Some(Duration::from_millis(100)),
1318        };
1319        let dbg = format!("{detail:?}");
1320        assert!(dbg.contains("RestartAllowed"));
1321        assert!(dbg.contains('3'));
1322
1323        let cloned = detail.clone();
1324        assert_eq!(detail, cloned);
1325    }
1326
1327    #[test]
1328    fn evidence_record_debug_clone_eq() {
1329        let record = EvidenceRecord {
1330            timestamp: 42,
1331            task_id: test_task_id(),
1332            region_id: test_region_id(),
1333            subsystem: Subsystem::Registry,
1334            verdict: Verdict::Accept,
1335            detail: EvidenceDetail::Registry(RegistryDetail::NameAvailable),
1336        };
1337        let dbg = format!("{record:?}");
1338        assert!(dbg.contains("EvidenceRecord"));
1339        assert!(dbg.contains("42"));
1340
1341        let cloned = record.clone();
1342        assert_eq!(record, cloned);
1343    }
1344
1345    #[test]
1346    fn evidence_card_debug_clone() {
1347        let record = EvidenceRecord {
1348            timestamp: 100,
1349            task_id: test_task_id(),
1350            region_id: test_region_id(),
1351            subsystem: Subsystem::Supervision,
1352            verdict: Verdict::Stop,
1353            detail: EvidenceDetail::Supervision(SupervisionDetail::ExplicitStop),
1354        };
1355        let card = record.to_card();
1356        let dbg = format!("{card:?}");
1357        assert!(dbg.contains("EvidenceCard"));
1358
1359        let cloned = card.clone();
1360        assert_eq!(card, cloned);
1361    }
1362
1363    #[test]
1364    fn generalized_ledger_debug_clone_default() {
1365        let ledger = GeneralizedLedger::default();
1366        assert!(ledger.is_empty());
1367        assert_eq!(ledger.len(), 0);
1368
1369        let dbg = format!("{ledger:?}");
1370        assert!(dbg.contains("GeneralizedLedger"));
1371
1372        let mut ledger2 = GeneralizedLedger::new();
1373        ledger2.push(EvidenceRecord {
1374            timestamp: 1,
1375            task_id: test_task_id(),
1376            region_id: test_region_id(),
1377            subsystem: Subsystem::Monitor,
1378            verdict: Verdict::Deliver,
1379            detail: EvidenceDetail::Monitor(MonitorDetail::Demonitored),
1380        });
1381        let cloned = ledger2.clone();
1382        assert_eq!(cloned.len(), 1);
1383        assert_eq!(cloned.entries()[0].timestamp, 1);
1384    }
1385}