Skip to main content

perspt_agent/
ledger.rs

1//! DuckDB Merkle Ledger
2//!
3//! Persistent storage for session history, commits, and Merkle proofs.
4
5use anyhow::{Context, Result};
6pub use perspt_store::{LlmRequestRecord, NodeStateRecord, SessionRecord, SessionStore};
7use std::path::{Path, PathBuf};
8
9/// Full commit payload collected by the orchestrator at commit time.
10///
11/// Bundles graph-structural fields, retry/error metadata, and merkle
12/// material so that `commit_node_snapshot()` can persist a complete
13/// node record in a single call.
14#[derive(Debug, Clone)]
15pub struct NodeCommitPayload {
16    pub node_id: String,
17    pub state: String,
18    pub v_total: f32,
19    pub merkle_hash: Option<Vec<u8>>,
20    pub attempt_count: i32,
21    pub node_class: Option<String>,
22    pub owner_plugin: Option<String>,
23    pub goal: Option<String>,
24    pub parent_id: Option<String>,
25    /// JSON-serialized `Vec<String>` of child node IDs
26    pub children: Option<String>,
27    pub last_error_type: Option<String>,
28}
29
30/// Merkle commit record (Legacy wrapper for compatibility)
31#[derive(Debug, Clone)]
32pub struct MerkleCommit {
33    pub commit_id: String,
34    pub session_id: String,
35    pub node_id: String,
36    pub merkle_root: [u8; 32],
37    pub parent_hash: Option<[u8; 32]>,
38    pub timestamp: i64,
39    pub energy: f32,
40    pub stable: bool,
41}
42
43/// Session record (Legacy wrapper for compatibility)
44#[derive(Debug, Clone)]
45pub struct SessionRecordLegacy {
46    pub session_id: String,
47    pub task: String,
48    pub started_at: i64,
49    pub ended_at: Option<i64>,
50    pub status: String,
51    pub total_nodes: usize,
52    pub completed_nodes: usize,
53}
54
55/// Merkle Ledger using DuckDB for persistence
56pub struct MerkleLedger {
57    /// Session store from perspt-store
58    store: SessionStore,
59    /// Current session metadata (legacy cache)
60    pub(crate) current_session: Option<SessionRecordLegacy>,
61    /// Session artifact directory
62    session_dir: Option<PathBuf>,
63}
64
65impl MerkleLedger {
66    /// Create a new ledger (opens or creates database)
67    pub fn new() -> Result<Self> {
68        let store = SessionStore::new().context("Failed to initialize session store")?;
69        Ok(Self {
70            store,
71            current_session: None,
72            session_dir: None,
73        })
74    }
75
76    /// Create an in-memory ledger (for testing)
77    pub fn in_memory() -> Result<Self> {
78        // Use a unique temp db for testing to avoid collisions
79        let temp_dir = std::env::temp_dir();
80        let db_path = temp_dir.join(format!("perspt_test_{}.db", uuid::Uuid::new_v4()));
81        let store = SessionStore::open(&db_path)?;
82        Ok(Self {
83            store,
84            current_session: None,
85            session_dir: None,
86        })
87    }
88
89    /// Start a new session
90    pub fn start_session(&mut self, session_id: &str, task: &str, working_dir: &str) -> Result<()> {
91        let record = SessionRecord {
92            session_id: session_id.to_string(),
93            task: task.to_string(),
94            working_dir: working_dir.to_string(),
95            merkle_root: None,
96            detected_toolchain: None,
97            status: "RUNNING".to_string(),
98        };
99
100        self.store.create_session(&record)?;
101
102        // Create physical artifact directory
103        let dir = self.store.create_session_dir(session_id)?;
104        self.session_dir = Some(dir);
105
106        let legacy_record = SessionRecordLegacy {
107            session_id: session_id.to_string(),
108            task: task.to_string(),
109            started_at: chrono_timestamp(),
110            ended_at: None,
111            status: "RUNNING".to_string(),
112            total_nodes: 0,
113            completed_nodes: 0,
114        };
115        self.current_session = Some(legacy_record);
116
117        log::info!("Started persistent session: {}", session_id);
118        Ok(())
119    }
120
121    /// Record energy measurement
122    pub fn record_energy(
123        &self,
124        node_id: &str,
125        energy: &crate::types::EnergyComponents,
126        total_energy: f32,
127    ) -> Result<()> {
128        let session_id = self
129            .current_session
130            .as_ref()
131            .map(|s| s.session_id.clone())
132            .context("No active session to record energy")?;
133
134        let record = perspt_store::EnergyRecord {
135            node_id: node_id.to_string(),
136            session_id,
137            v_syn: energy.v_syn,
138            v_str: energy.v_str,
139            v_log: energy.v_log,
140            v_boot: energy.v_boot,
141            v_sheaf: energy.v_sheaf,
142            v_total: total_energy,
143        };
144
145        self.store.record_energy(&record)?;
146        Ok(())
147    }
148
149    /// Commit a stable node state
150    pub fn commit_node(
151        &mut self,
152        node_id: &str,
153        merkle_root: [u8; 32],
154        _parent_hash: Option<[u8; 32]>,
155        energy: f32,
156        state_json: String,
157    ) -> Result<String> {
158        let session_id = self
159            .current_session
160            .as_ref()
161            .map(|s| s.session_id.clone())
162            .context("No active session to commit")?;
163
164        let commit_id = generate_commit_id();
165
166        let record = NodeStateRecord {
167            node_id: node_id.to_string(),
168            session_id: session_id.clone(),
169            state: state_json,
170            v_total: energy,
171            merkle_hash: Some(merkle_root.to_vec()),
172            attempt_count: 1, // Placeholder
173            // Phase 8 fields — populated properly via commit_node_snapshot
174            node_class: None,
175            owner_plugin: None,
176            goal: None,
177            parent_id: None,
178            children: None,
179            last_error_type: None,
180            committed_at: None,
181        };
182
183        self.store.record_node_state(&record)?;
184        self.store.update_merkle_root(&session_id, &merkle_root)?;
185
186        log::info!("Committed node {} to store", node_id);
187
188        // Update session progress
189        if let Some(ref mut session) = self.current_session {
190            session.completed_nodes += 1;
191        }
192
193        Ok(commit_id)
194    }
195
196    /// Commit a full node snapshot with all Phase 8 metadata.
197    ///
198    /// This is the preferred commit API for the orchestrator. It records the
199    /// complete node state, graph-structural fields, retry/error metadata,
200    /// and merkle material in a single durable write. Returns the commit ID.
201    pub fn commit_node_snapshot(&mut self, payload: &NodeCommitPayload) -> Result<String> {
202        let session_id = self
203            .current_session
204            .as_ref()
205            .map(|s| s.session_id.clone())
206            .context("No active session to commit")?;
207
208        let commit_id = generate_commit_id();
209
210        let record = NodeStateRecord {
211            node_id: payload.node_id.clone(),
212            session_id: session_id.clone(),
213            state: payload.state.clone(),
214            v_total: payload.v_total,
215            merkle_hash: payload.merkle_hash.clone(),
216            attempt_count: payload.attempt_count,
217            node_class: payload.node_class.clone(),
218            owner_plugin: payload.owner_plugin.clone(),
219            goal: payload.goal.clone(),
220            parent_id: payload.parent_id.clone(),
221            children: payload.children.clone(),
222            last_error_type: payload.last_error_type.clone(),
223            committed_at: Some(chrono_iso_now()),
224        };
225
226        self.store.record_node_state(&record)?;
227
228        // Update merkle root if hash is present
229        if let Some(ref hash) = payload.merkle_hash {
230            if hash.len() == 32 {
231                let mut root = [0u8; 32];
232                root.copy_from_slice(hash);
233                self.store.update_merkle_root(&session_id, &root)?;
234            }
235        }
236
237        log::info!(
238            "Committed node snapshot '{}' (state={}, attempts={})",
239            payload.node_id,
240            payload.state,
241            payload.attempt_count
242        );
243
244        if let Some(ref mut session) = self.current_session {
245            session.completed_nodes += 1;
246        }
247
248        Ok(commit_id)
249    }
250
251    /// End the current session
252    pub fn end_session(&mut self, status: &str) -> Result<()> {
253        if let Some(ref mut session) = self.current_session {
254            session.ended_at = Some(chrono_timestamp());
255            session.status = status.to_string();
256            // Persist status to durable store
257            self.store
258                .update_session_status(&session.session_id, status)?;
259            log::info!(
260                "Ended session {} with status: {}",
261                session.session_id,
262                status
263            );
264        }
265        Ok(())
266    }
267
268    /// Get artifacts directory
269    pub fn artifacts_dir(&self) -> Option<&Path> {
270        self.session_dir.as_deref()
271    }
272
273    /// Get session statistics (legacy facade)
274    pub fn get_stats(&self) -> LedgerStats {
275        LedgerStats {
276            total_sessions: 0, // Would query store.count_sessions()
277            total_commits: 0,
278            db_size_bytes: 0,
279        }
280    }
281
282    /// Get the current merkle root (legacy facade)
283    pub fn current_merkle_root(&self) -> [u8; 32] {
284        [0u8; 32] // Placeholder
285    }
286
287    /// Record an LLM request/response for debugging and cost tracking
288    #[allow(clippy::too_many_arguments)]
289    pub fn record_llm_request(
290        &self,
291        model: &str,
292        prompt: &str,
293        response: &str,
294        node_id: Option<&str>,
295        latency_ms: i32,
296        tokens_in: i32,
297        tokens_out: i32,
298    ) -> Result<()> {
299        let session_id = self
300            .current_session
301            .as_ref()
302            .map(|s| s.session_id.clone())
303            .context("No active session to record LLM request")?;
304
305        let record = LlmRequestRecord {
306            session_id,
307            node_id: node_id.map(|s| s.to_string()),
308            model: model.to_string(),
309            prompt: prompt.to_string(),
310            response: response.to_string(),
311            tokens_in,
312            tokens_out,
313            latency_ms,
314        };
315
316        self.store.record_llm_request(&record)?;
317        log::debug!(
318            "Recorded LLM request: model={}, prompt_len={}, response_len={}",
319            model,
320            prompt.len(),
321            response.len()
322        );
323        Ok(())
324    }
325
326    /// Get access to the underlying store (for direct queries)
327    pub fn store(&self) -> &SessionStore {
328        &self.store
329    }
330
331    // =========================================================================
332    // PSP-5 Phase 3: Structural Digests & Context Provenance
333    // =========================================================================
334
335    /// Record a structural digest for a node
336    pub fn record_structural_digest(
337        &self,
338        node_id: &str,
339        source_path: &str,
340        artifact_kind: &str,
341        hash: &[u8],
342        version: i32,
343    ) -> Result<()> {
344        let session_id = self
345            .current_session
346            .as_ref()
347            .map(|s| s.session_id.clone())
348            .context("No active session to record structural digest")?;
349
350        let record = perspt_store::StructuralDigestRecord {
351            digest_id: format!("sd-{}-{}", node_id, uuid::Uuid::new_v4()),
352            session_id,
353            node_id: node_id.to_string(),
354            source_path: source_path.to_string(),
355            artifact_kind: artifact_kind.to_string(),
356            hash: hash.to_vec(),
357            version,
358        };
359
360        self.store.record_structural_digest(&record)?;
361        log::debug!(
362            "Recorded structural digest for {} at {}",
363            node_id,
364            source_path
365        );
366        Ok(())
367    }
368
369    /// Get structural digests for a specific node in the current session
370    pub fn get_structural_digests(
371        &self,
372        node_id: &str,
373    ) -> Result<Vec<perspt_store::StructuralDigestRecord>> {
374        let session_id = self
375            .current_session
376            .as_ref()
377            .map(|s| s.session_id.clone())
378            .context("No active session to query structural digests")?;
379
380        self.store.get_structural_digests(&session_id, node_id)
381    }
382
383    /// Record context provenance for a node
384    pub fn record_context_provenance(
385        &self,
386        provenance: &perspt_core::types::ContextProvenance,
387    ) -> Result<()> {
388        let session_id = self
389            .current_session
390            .as_ref()
391            .map(|s| s.session_id.clone())
392            .context("No active session to record context provenance")?;
393
394        let to_hex_32 =
395            |bytes: &[u8; 32]| -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() };
396        let to_hex_vec =
397            |bytes: &[u8]| -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() };
398        let structural_hashes: Vec<String> = provenance
399            .structural_digest_hashes
400            .iter()
401            .map(|(id, hash)| format!("{}:{}", id, to_hex_32(hash)))
402            .collect();
403        let summary_hashes: Vec<String> = provenance
404            .summary_digest_hashes
405            .iter()
406            .map(|(id, hash)| format!("{}:{}", id, to_hex_32(hash)))
407            .collect();
408        let dep_hashes: Vec<String> = provenance
409            .dependency_commit_hashes
410            .iter()
411            .map(|(id, hash)| format!("{}:{}", id, to_hex_vec(hash)))
412            .collect();
413
414        let record = perspt_store::ContextProvenanceRecord {
415            session_id,
416            node_id: provenance.node_id.clone(),
417            context_package_id: provenance.context_package_id.clone(),
418            structural_hashes: serde_json::to_string(&structural_hashes).unwrap_or_default(),
419            summary_hashes: serde_json::to_string(&summary_hashes).unwrap_or_default(),
420            dependency_hashes: serde_json::to_string(&dep_hashes).unwrap_or_default(),
421            included_file_count: provenance.included_file_count as i32,
422            total_bytes: provenance.total_bytes as i32,
423        };
424
425        self.store.record_context_provenance(&record)?;
426        log::debug!(
427            "Recorded context provenance for node '{}' (package '{}')",
428            provenance.node_id,
429            provenance.context_package_id
430        );
431        Ok(())
432    }
433
434    /// Get context provenance for a specific node in the current session
435    pub fn get_context_provenance(
436        &self,
437        node_id: &str,
438    ) -> Result<Option<perspt_store::ContextProvenanceRecord>> {
439        let session_id = self
440            .current_session
441            .as_ref()
442            .map(|s| s.session_id.clone())
443            .context("No active session to query context provenance")?;
444
445        self.store.get_context_provenance(&session_id, node_id)
446    }
447
448    // =========================================================================
449    // PSP-5 Phase 5: Escalation and Rewrite Persistence
450    // =========================================================================
451
452    /// Record an escalation report for a non-convergent node
453    pub fn record_escalation_report(
454        &self,
455        report: &perspt_core::types::EscalationReport,
456    ) -> Result<()> {
457        let session_id = self
458            .current_session
459            .as_ref()
460            .map(|s| s.session_id.clone())
461            .context("No active session to record escalation report")?;
462
463        let record = perspt_store::EscalationReportRecord {
464            session_id,
465            node_id: report.node_id.clone(),
466            category: report.category.to_string(),
467            action: serde_json::to_string(&report.action).unwrap_or_default(),
468            energy_snapshot: serde_json::to_string(&report.energy_snapshot).unwrap_or_default(),
469            stage_outcomes: serde_json::to_string(&report.stage_outcomes).unwrap_or_default(),
470            evidence: report.evidence.clone(),
471            affected_node_ids: serde_json::to_string(&report.affected_node_ids).unwrap_or_default(),
472        };
473
474        self.store.record_escalation_report(&record)?;
475        log::debug!(
476            "Recorded escalation report for node '{}': {} → {}",
477            report.node_id,
478            report.category,
479            report.action
480        );
481        Ok(())
482    }
483
484    /// Record a local graph rewrite
485    pub fn record_rewrite(&self, record: &perspt_core::types::RewriteRecord) -> Result<()> {
486        let session_id = self
487            .current_session
488            .as_ref()
489            .map(|s| s.session_id.clone())
490            .context("No active session to record rewrite")?;
491
492        let row = perspt_store::RewriteRecordRow {
493            session_id,
494            node_id: record.node_id.clone(),
495            action: serde_json::to_string(&record.action).unwrap_or_default(),
496            category: record.category.to_string(),
497            requeued_nodes: serde_json::to_string(&record.requeued_nodes).unwrap_or_default(),
498            inserted_nodes: serde_json::to_string(&record.inserted_nodes).unwrap_or_default(),
499        };
500
501        self.store.record_rewrite(&row)?;
502        log::debug!(
503            "Recorded rewrite for node '{}': {} ({} requeued, {} inserted)",
504            record.node_id,
505            record.action,
506            record.requeued_nodes.len(),
507            record.inserted_nodes.len()
508        );
509        Ok(())
510    }
511
512    /// PSP-5 Phase 5: Count rewrite records matching a lineage prefix.
513    ///
514    /// A lineage is identified by the base node ID (before any `__split_` or
515    /// `__iface` suffixes). This count is used as a churn guardrail to prevent
516    /// infinite rewrite loops.
517    pub fn get_rewrite_count_for_lineage(&self, lineage_base: &str) -> Result<usize> {
518        let session_id = self
519            .current_session
520            .as_ref()
521            .map(|s| s.session_id.clone())
522            .context("No active session to query rewrite count")?;
523
524        let records = self.store.get_rewrite_records(&session_id)?;
525        let count = records
526            .iter()
527            .filter(|r| r.node_id.starts_with(lineage_base))
528            .count();
529        Ok(count)
530    }
531
532    /// Record a sheaf validation result
533    pub fn record_sheaf_validation(
534        &self,
535        node_id: &str,
536        result: &perspt_core::types::SheafValidationResult,
537    ) -> Result<()> {
538        let session_id = self
539            .current_session
540            .as_ref()
541            .map(|s| s.session_id.clone())
542            .context("No active session to record sheaf validation")?;
543
544        let row = perspt_store::SheafValidationRow {
545            session_id,
546            node_id: node_id.to_string(),
547            validator_class: result.validator_class.to_string(),
548            plugin_source: result.plugin_source.clone(),
549            passed: result.passed,
550            evidence_summary: result.evidence_summary.clone(),
551            affected_files: serde_json::to_string(&result.affected_files).unwrap_or_default(),
552            v_sheaf_contribution: result.v_sheaf_contribution,
553            requeue_targets: serde_json::to_string(&result.requeue_targets).unwrap_or_default(),
554        };
555
556        self.store.record_sheaf_validation(&row)?;
557        log::debug!(
558            "Recorded sheaf validation for node '{}': {} → {}",
559            node_id,
560            result.validator_class,
561            if result.passed { "pass" } else { "fail" }
562        );
563        Ok(())
564    }
565
566    /// Get escalation reports for the current session
567    pub fn get_escalation_reports(&self) -> Result<Vec<perspt_store::EscalationReportRecord>> {
568        let session_id = self
569            .current_session
570            .as_ref()
571            .map(|s| s.session_id.clone())
572            .context("No active session to query escalation reports")?;
573
574        self.store.get_escalation_reports(&session_id)
575    }
576
577    // =========================================================================
578    // PSP-5 Phase 8: Verification Result and Artifact Bundle Facades
579    // =========================================================================
580
581    /// Record a verification result snapshot for a node
582    pub fn record_verification_result(
583        &self,
584        node_id: &str,
585        result: &perspt_core::types::VerificationResult,
586    ) -> Result<()> {
587        let session_id = self.session_id()?;
588
589        let result_json = serde_json::to_string(result).unwrap_or_default();
590        let row = perspt_store::VerificationResultRow {
591            session_id,
592            node_id: node_id.to_string(),
593            result_json,
594            syntax_ok: result.syntax_ok,
595            build_ok: result.build_ok,
596            tests_ok: result.tests_ok,
597            lint_ok: result.lint_ok,
598            diagnostics_count: result.diagnostics_count as i32,
599            tests_passed: result.tests_passed as i32,
600            tests_failed: result.tests_failed as i32,
601            degraded: result.degraded,
602            degraded_reason: result.degraded_reason.clone(),
603        };
604
605        self.store.record_verification_result(&row)?;
606        log::debug!(
607            "Recorded verification result for node '{}': syn={} build={} test={} degraded={}",
608            node_id,
609            result.syntax_ok,
610            result.build_ok,
611            result.tests_ok,
612            result.degraded
613        );
614        Ok(())
615    }
616
617    /// Get the latest verification result for a node
618    pub fn get_verification_result(
619        &self,
620        node_id: &str,
621    ) -> Result<Option<perspt_store::VerificationResultRow>> {
622        let session_id = self.session_id()?;
623        self.store.get_verification_result(&session_id, node_id)
624    }
625
626    /// Record an artifact bundle snapshot for a node
627    pub fn record_artifact_bundle(
628        &self,
629        node_id: &str,
630        bundle: &perspt_core::types::ArtifactBundle,
631    ) -> Result<()> {
632        let session_id = self.session_id()?;
633
634        let bundle_json = serde_json::to_string(bundle).unwrap_or_default();
635        let touched_files: Vec<String> = bundle
636            .artifacts
637            .iter()
638            .map(|a| a.path().to_string())
639            .collect();
640
641        let row = perspt_store::ArtifactBundleRow {
642            session_id,
643            node_id: node_id.to_string(),
644            bundle_json,
645            artifact_count: bundle.artifacts.len() as i32,
646            command_count: bundle.commands.len() as i32,
647            touched_files: serde_json::to_string(&touched_files).unwrap_or_default(),
648        };
649
650        self.store.record_artifact_bundle(&row)?;
651        log::debug!(
652            "Recorded artifact bundle for node '{}': {} artifacts, {} commands",
653            node_id,
654            bundle.artifacts.len(),
655            bundle.commands.len()
656        );
657        Ok(())
658    }
659
660    /// Get the latest artifact bundle for a node
661    pub fn get_artifact_bundle(
662        &self,
663        node_id: &str,
664    ) -> Result<Option<perspt_store::ArtifactBundleRow>> {
665        let session_id = self.session_id()?;
666        self.store.get_artifact_bundle(&session_id, node_id)
667    }
668
669    // =========================================================================
670    // PSP-5 Phase 8: Task Graph & Session Rehydration
671    // =========================================================================
672
673    /// Record a task-graph edge (parent→child dependency)
674    pub fn record_task_graph_edge(
675        &self,
676        parent_node_id: &str,
677        child_node_id: &str,
678        edge_type: &str,
679    ) -> Result<()> {
680        let session_id = self.session_id()?;
681        let row = perspt_store::TaskGraphEdgeRow {
682            session_id,
683            parent_node_id: parent_node_id.to_string(),
684            child_node_id: child_node_id.to_string(),
685            edge_type: edge_type.to_string(),
686        };
687        self.store.record_task_graph_edge(&row)?;
688        log::debug!(
689            "Recorded task graph edge: {} → {} ({})",
690            parent_node_id,
691            child_node_id,
692            edge_type
693        );
694        Ok(())
695    }
696
697    /// Get all task graph edges for the current session
698    pub fn get_task_graph_edges(&self) -> Result<Vec<perspt_store::TaskGraphEdgeRow>> {
699        let session_id = self.session_id()?;
700        self.store.get_task_graph_edges(&session_id)
701    }
702
703    /// Get sheaf validations for a specific node
704    pub fn get_sheaf_validations(
705        &self,
706        node_id: &str,
707    ) -> Result<Vec<perspt_store::SheafValidationRow>> {
708        let session_id = self.session_id()?;
709        self.store.get_sheaf_validations(&session_id, node_id)
710    }
711
712    /// Load a complete session snapshot for rehydration/resume.
713    ///
714    /// Aggregates the latest node states, graph topology, energy history,
715    /// verification results, artifact bundles, sheaf validations,
716    /// provisional branches, interface seals, context provenance, and
717    /// escalation reports into a single `SessionSnapshot`.
718    pub fn load_session_snapshot(&self) -> Result<SessionSnapshot> {
719        let session_id = self.session_id()?;
720
721        let node_states = self
722            .store
723            .get_latest_node_states(&session_id)
724            .unwrap_or_default();
725
726        let graph_edges = self
727            .store
728            .get_task_graph_edges(&session_id)
729            .unwrap_or_default();
730
731        let branches = self
732            .store
733            .get_provisional_branches(&session_id)
734            .unwrap_or_default();
735
736        let escalation_reports = self
737            .store
738            .get_escalation_reports(&session_id)
739            .unwrap_or_default();
740
741        let flushes = self
742            .store
743            .get_branch_flushes(&session_id)
744            .unwrap_or_default();
745
746        // Collect per-node evidence
747        let mut node_details: Vec<NodeSnapshotDetail> = Vec::with_capacity(node_states.len());
748        for ns in &node_states {
749            let nid = &ns.node_id;
750
751            let energy_history = self
752                .store
753                .get_energy_history(&session_id, nid)
754                .unwrap_or_default();
755
756            let verification = self
757                .store
758                .get_verification_result(&session_id, nid)
759                .ok()
760                .flatten();
761
762            let artifact_bundle = self
763                .store
764                .get_artifact_bundle(&session_id, nid)
765                .ok()
766                .flatten();
767
768            let sheaf_validations = self
769                .store
770                .get_sheaf_validations(&session_id, nid)
771                .unwrap_or_default();
772
773            let interface_seals = self
774                .store
775                .get_interface_seals(&session_id, nid)
776                .unwrap_or_default();
777
778            let context_provenance = self
779                .store
780                .get_context_provenance(&session_id, nid)
781                .ok()
782                .flatten();
783
784            node_details.push(NodeSnapshotDetail {
785                record: ns.clone(),
786                energy_history,
787                verification,
788                artifact_bundle,
789                sheaf_validations,
790                interface_seals,
791                context_provenance,
792            });
793        }
794
795        log::info!(
796            "Loaded session snapshot: {} nodes, {} edges, {} branches",
797            node_details.len(),
798            graph_edges.len(),
799            branches.len()
800        );
801
802        Ok(SessionSnapshot {
803            session_id,
804            node_details,
805            graph_edges,
806            branches,
807            escalation_reports,
808            flushes,
809        })
810    }
811
812    // =========================================================================
813    // PSP-5 Phase 6: Provisional Branch, Interface Seal, Branch Flush Facades
814    // =========================================================================
815
816    /// Get the current session ID (helper for Phase 6 methods)
817    fn session_id(&self) -> Result<String> {
818        self.current_session
819            .as_ref()
820            .map(|s| s.session_id.clone())
821            .context("No active session")
822    }
823
824    /// Record a new provisional branch for speculative child work
825    pub fn record_provisional_branch(
826        &self,
827        branch: &perspt_core::types::ProvisionalBranch,
828    ) -> Result<()> {
829        let row = perspt_store::ProvisionalBranchRow {
830            branch_id: branch.branch_id.clone(),
831            session_id: branch.session_id.clone(),
832            node_id: branch.node_id.clone(),
833            parent_node_id: branch.parent_node_id.clone(),
834            state: branch.state.to_string(),
835            parent_seal_hash: branch.parent_seal_hash.map(|h| h.to_vec()),
836            sandbox_dir: branch.sandbox_dir.clone(),
837        };
838
839        self.store.record_provisional_branch(&row)?;
840        log::debug!(
841            "Recorded provisional branch '{}' for node '{}' (parent: '{}')",
842            branch.branch_id,
843            branch.node_id,
844            branch.parent_node_id
845        );
846        Ok(())
847    }
848
849    /// Update a provisional branch state
850    pub fn update_branch_state(&self, branch_id: &str, new_state: &str) -> Result<()> {
851        self.store.update_branch_state(branch_id, new_state)?;
852        log::debug!("Updated branch '{}' state to '{}'", branch_id, new_state);
853        Ok(())
854    }
855
856    /// Get all provisional branches for the current session
857    pub fn get_provisional_branches(&self) -> Result<Vec<perspt_store::ProvisionalBranchRow>> {
858        let session_id = self.session_id()?;
859        self.store.get_provisional_branches(&session_id)
860    }
861
862    /// Get live (active/sealed) branches depending on a parent node
863    pub fn get_live_branches_for_parent(
864        &self,
865        parent_node_id: &str,
866    ) -> Result<Vec<perspt_store::ProvisionalBranchRow>> {
867        let session_id = self.session_id()?;
868        self.store
869            .get_live_branches_for_parent(&session_id, parent_node_id)
870    }
871
872    /// Flush all live branches for a parent node and return flushed branch IDs
873    pub fn flush_branches_for_parent(&self, parent_node_id: &str) -> Result<Vec<String>> {
874        let session_id = self.session_id()?;
875        self.store
876            .flush_branches_for_parent(&session_id, parent_node_id)
877    }
878
879    /// Record a branch lineage edge (parent branch → child branch)
880    pub fn record_branch_lineage(&self, lineage: &perspt_core::types::BranchLineage) -> Result<()> {
881        let row = perspt_store::BranchLineageRow {
882            lineage_id: lineage.lineage_id.clone(),
883            parent_branch_id: lineage.parent_branch_id.clone(),
884            child_branch_id: lineage.child_branch_id.clone(),
885            depends_on_seal: lineage.depends_on_seal,
886        };
887
888        self.store.record_branch_lineage(&row)?;
889        log::debug!(
890            "Recorded branch lineage: {} → {}",
891            lineage.parent_branch_id,
892            lineage.child_branch_id
893        );
894        Ok(())
895    }
896
897    /// Get child branch IDs for a parent branch
898    pub fn get_child_branches(&self, parent_branch_id: &str) -> Result<Vec<String>> {
899        self.store.get_child_branches(parent_branch_id)
900    }
901
902    /// Record an interface seal for a node
903    pub fn record_interface_seal(
904        &self,
905        seal: &perspt_core::types::InterfaceSealRecord,
906    ) -> Result<()> {
907        let row = perspt_store::InterfaceSealRow {
908            seal_id: seal.seal_id.clone(),
909            session_id: seal.session_id.clone(),
910            node_id: seal.node_id.clone(),
911            sealed_path: seal.sealed_path.clone(),
912            artifact_kind: seal.artifact_kind.to_string(),
913            seal_hash: seal.seal_hash.to_vec(),
914            version: seal.version as i32,
915        };
916
917        self.store.record_interface_seal(&row)?;
918        log::debug!(
919            "Recorded interface seal '{}' for node '{}' at '{}'",
920            seal.seal_id,
921            seal.node_id,
922            seal.sealed_path
923        );
924        Ok(())
925    }
926
927    /// Get all interface seals for a node in the current session
928    pub fn get_interface_seals(
929        &self,
930        node_id: &str,
931    ) -> Result<Vec<perspt_store::InterfaceSealRow>> {
932        let session_id = self.session_id()?;
933        self.store.get_interface_seals(&session_id, node_id)
934    }
935
936    /// Check whether a node has any interface seals
937    pub fn has_interface_seals(&self, node_id: &str) -> Result<bool> {
938        let session_id = self.session_id()?;
939        self.store.has_interface_seals(&session_id, node_id)
940    }
941
942    /// Record a branch flush decision
943    pub fn record_branch_flush(&self, flush: &perspt_core::types::BranchFlushRecord) -> Result<()> {
944        let row = perspt_store::BranchFlushRow {
945            flush_id: flush.flush_id.clone(),
946            session_id: flush.session_id.clone(),
947            parent_node_id: flush.parent_node_id.clone(),
948            flushed_branch_ids: serde_json::to_string(&flush.flushed_branch_ids)
949                .unwrap_or_default(),
950            requeue_node_ids: serde_json::to_string(&flush.requeue_node_ids).unwrap_or_default(),
951            reason: flush.reason.clone(),
952        };
953
954        self.store.record_branch_flush(&row)?;
955        log::debug!(
956            "Recorded branch flush for parent '{}': {} branches flushed",
957            flush.parent_node_id,
958            flush.flushed_branch_ids.len()
959        );
960        Ok(())
961    }
962
963    /// Get all branch flush records for the current session
964    pub fn get_branch_flushes(&self) -> Result<Vec<perspt_store::BranchFlushRow>> {
965        let session_id = self.session_id()?;
966        self.store.get_branch_flushes(&session_id)
967    }
968
969    // =========================================================================
970    // PSP-5 Phase 7: Review Outcome Persistence
971    // =========================================================================
972
973    /// Persist a review decision as an audit record.
974    pub fn record_review_outcome(
975        &self,
976        node_id: &str,
977        outcome: &str,
978        reviewer_note: Option<&str>,
979        energy_at_review: Option<f64>,
980        degraded: Option<bool>,
981        escalation_category: Option<&str>,
982    ) -> Result<()> {
983        let session_id = self.session_id()?;
984        let row = perspt_store::ReviewOutcomeRow {
985            session_id,
986            node_id: node_id.to_string(),
987            outcome: outcome.to_string(),
988            reviewer_note: reviewer_note.map(|s| s.to_string()),
989            energy_at_review,
990            degraded,
991            escalation_category: escalation_category.map(|s| s.to_string()),
992        };
993        self.store.record_review_outcome(&row)
994    }
995
996    /// Get all review outcomes for a node.
997    pub fn get_review_outcomes(
998        &self,
999        node_id: &str,
1000    ) -> Result<Vec<perspt_store::ReviewOutcomeRow>> {
1001        let session_id = self.session_id()?;
1002        self.store.get_review_outcomes(&session_id, node_id)
1003    }
1004
1005    /// Get all review outcomes across the session.
1006    pub fn get_all_review_outcomes(&self) -> Result<Vec<perspt_store::ReviewOutcomeRow>> {
1007        let session_id = self.session_id()?;
1008        self.store.get_all_review_outcomes(&session_id)
1009    }
1010
1011    // =========================================================================
1012    // PSP-5 Phase 7: Shared Review & Provenance Aggregation Helpers
1013    // =========================================================================
1014
1015    /// Build a review-ready summary for a single node.
1016    ///
1017    /// Aggregates energy history, escalation reports, sheaf validations,
1018    /// context provenance, interface seals, and branch state from the store
1019    /// into a single struct consumable by both TUI and CLI surfaces.
1020    pub fn node_review_summary(&self, node_id: &str) -> Result<NodeReviewSummary> {
1021        let session_id = self.session_id()?;
1022
1023        let energy_history = self
1024            .store
1025            .get_energy_history(&session_id, node_id)
1026            .unwrap_or_default();
1027
1028        let latest_energy = energy_history.last().cloned();
1029
1030        let escalation_reports = self
1031            .store
1032            .get_escalation_reports(&session_id)
1033            .unwrap_or_default()
1034            .into_iter()
1035            .filter(|r| r.node_id == node_id)
1036            .collect::<Vec<_>>();
1037
1038        let sheaf_validations = self
1039            .store
1040            .get_sheaf_validations(&session_id, node_id)
1041            .unwrap_or_default();
1042
1043        let interface_seals = self
1044            .store
1045            .get_interface_seals(&session_id, node_id)
1046            .unwrap_or_default();
1047
1048        let context_provenance = self
1049            .store
1050            .get_context_provenance(&session_id, node_id)
1051            .ok()
1052            .flatten()
1053            .into_iter()
1054            .collect::<Vec<_>>();
1055
1056        let branches: Vec<_> = self
1057            .store
1058            .get_provisional_branches(&session_id)
1059            .unwrap_or_default()
1060            .into_iter()
1061            .filter(|b| b.node_id == node_id)
1062            .collect();
1063
1064        let attempt_count = energy_history.len().max(1) as u32;
1065
1066        Ok(NodeReviewSummary {
1067            node_id: node_id.to_string(),
1068            latest_energy,
1069            energy_history,
1070            attempt_count,
1071            escalation_reports,
1072            sheaf_validations,
1073            interface_seals,
1074            context_provenance,
1075            branches,
1076        })
1077    }
1078
1079    /// Build a session-level summary aggregating lifecycle counts, energy
1080    /// stats, escalation activity, and branch provenance.
1081    pub fn session_summary(&self) -> Result<SessionReviewSummary> {
1082        let session_id = self.session_id()?;
1083
1084        let node_states = self.store.get_node_states(&session_id).unwrap_or_default();
1085        let total_nodes = node_states.len();
1086        let completed = node_states
1087            .iter()
1088            .filter(|n| n.state == "COMPLETED" || n.state == "STABLE")
1089            .count();
1090        let failed = node_states.iter().filter(|n| n.state == "FAILED").count();
1091        let escalated = node_states
1092            .iter()
1093            .filter(|n| n.state == "Escalated")
1094            .count();
1095
1096        // Collect latest energy per node
1097        let mut total_energy: f32 = 0.0;
1098        let mut node_energies: Vec<(String, perspt_store::EnergyRecord)> = Vec::new();
1099        for ns in &node_states {
1100            if let Ok(history) = self.store.get_energy_history(&session_id, &ns.node_id) {
1101                if let Some(latest) = history.last() {
1102                    total_energy += latest.v_total;
1103                    node_energies.push((ns.node_id.clone(), latest.clone()));
1104                }
1105            }
1106        }
1107
1108        let escalation_reports = self
1109            .store
1110            .get_escalation_reports(&session_id)
1111            .unwrap_or_default();
1112
1113        let branches = self
1114            .store
1115            .get_provisional_branches(&session_id)
1116            .unwrap_or_default();
1117
1118        let active_branches = branches.iter().filter(|b| b.state == "active").count();
1119        let sealed_branches = branches.iter().filter(|b| b.state == "sealed").count();
1120        let merged_branches = branches.iter().filter(|b| b.state == "merged").count();
1121        let flushed_branches = branches.iter().filter(|b| b.state == "flushed").count();
1122
1123        let flushes = self
1124            .store
1125            .get_branch_flushes(&session_id)
1126            .unwrap_or_default();
1127
1128        // Review audit aggregation
1129        let review_outcomes = self
1130            .store
1131            .get_all_review_outcomes(&session_id)
1132            .unwrap_or_default();
1133        let review_total = review_outcomes.len();
1134        let reviews_approved = review_outcomes
1135            .iter()
1136            .filter(|r| r.outcome.starts_with("approved") || r.outcome == "auto_approved")
1137            .count();
1138        let reviews_rejected = review_outcomes
1139            .iter()
1140            .filter(|r| r.outcome == "rejected" || r.outcome == "aborted")
1141            .count();
1142        let reviews_corrected = review_outcomes
1143            .iter()
1144            .filter(|r| r.outcome == "correction_requested")
1145            .count();
1146
1147        Ok(SessionReviewSummary {
1148            session_id,
1149            total_nodes,
1150            completed,
1151            failed,
1152            escalated,
1153            total_energy,
1154            node_energies,
1155            escalation_reports,
1156            branches_total: branches.len(),
1157            active_branches,
1158            sealed_branches,
1159            merged_branches,
1160            flushed_branches,
1161            flush_decisions: flushes,
1162            review_total,
1163            reviews_approved,
1164            reviews_rejected,
1165            reviews_corrected,
1166        })
1167    }
1168}
1169
1170// =========================================================================
1171// Plan Revision, Feature Charter, Repair Footprint, Budget Envelope Facades
1172// =========================================================================
1173
1174impl MerkleLedger {
1175    /// Record a feature charter for the current session.
1176    pub fn record_feature_charter(&self, charter: &perspt_core::FeatureCharter) -> Result<()> {
1177        let session_id = self.session_id()?;
1178        let row = perspt_store::FeatureCharterRow {
1179            charter_id: charter.charter_id.clone(),
1180            session_id,
1181            scope_description: charter.scope_description.clone(),
1182            max_modules: charter.max_modules.map(|v| v as i32),
1183            max_files: charter.max_files.map(|v| v as i32),
1184            max_revisions: charter.max_revisions.map(|v| v as i32),
1185            language_constraint: charter.language_constraint.clone(),
1186        };
1187        self.store.record_feature_charter(&row)?;
1188        log::debug!("Recorded feature charter '{}'", charter.charter_id);
1189        Ok(())
1190    }
1191
1192    /// Get the feature charter for the current session.
1193    pub fn get_feature_charter(&self) -> Result<Option<perspt_store::FeatureCharterRow>> {
1194        let session_id = self.session_id()?;
1195        self.store.get_feature_charter(&session_id)
1196    }
1197
1198    /// Record a plan revision for the current session.
1199    pub fn record_plan_revision(&self, revision: &perspt_core::PlanRevision) -> Result<()> {
1200        let session_id = self.session_id()?;
1201        let plan_json = serde_json::to_string(&revision.plan).unwrap_or_default();
1202        let row = perspt_store::PlanRevisionRow {
1203            revision_id: revision.revision_id.clone(),
1204            session_id,
1205            sequence: revision.sequence as i32,
1206            plan_json,
1207            reason: revision.reason.clone(),
1208            supersedes: revision.supersedes.clone(),
1209            status: revision.status.to_string(),
1210        };
1211        self.store.record_plan_revision(&row)?;
1212        log::debug!(
1213            "Recorded plan revision '{}' (seq={}, status={})",
1214            revision.revision_id,
1215            revision.sequence,
1216            revision.status
1217        );
1218        Ok(())
1219    }
1220
1221    /// Get the active plan revision for the current session.
1222    pub fn get_active_plan_revision(&self) -> Result<Option<perspt_store::PlanRevisionRow>> {
1223        let session_id = self.session_id()?;
1224        self.store.get_active_plan_revision(&session_id)
1225    }
1226
1227    /// Get all plan revisions for the current session.
1228    pub fn get_plan_revisions(&self) -> Result<Vec<perspt_store::PlanRevisionRow>> {
1229        let session_id = self.session_id()?;
1230        self.store.get_plan_revisions(&session_id)
1231    }
1232
1233    /// Supersede a plan revision by ID.
1234    pub fn supersede_plan_revision(&self, revision_id: &str) -> Result<()> {
1235        self.store.supersede_plan_revision(revision_id)?;
1236        log::debug!("Superseded plan revision '{}'", revision_id);
1237        Ok(())
1238    }
1239
1240    /// Record a repair footprint for a node.
1241    pub fn record_repair_footprint(&self, footprint: &perspt_core::RepairFootprint) -> Result<()> {
1242        let session_id = self.session_id()?;
1243        let row = perspt_store::RepairFootprintRow {
1244            footprint_id: footprint.footprint_id.clone(),
1245            session_id,
1246            node_id: footprint.node_id.clone(),
1247            revision_id: footprint.revision_id.clone(),
1248            attempt: footprint.attempt as i32,
1249            affected_files: serde_json::to_string(&footprint.affected_files).unwrap_or_default(),
1250            bundle_json: serde_json::to_string(&footprint.applied_bundle).unwrap_or_default(),
1251            diagnosis: footprint.diagnosis.clone(),
1252            resolved: footprint.resolved,
1253        };
1254        self.store.record_repair_footprint(&row)?;
1255        log::debug!(
1256            "Recorded repair footprint '{}' for node '{}' (attempt {})",
1257            footprint.footprint_id,
1258            footprint.node_id,
1259            footprint.attempt
1260        );
1261        Ok(())
1262    }
1263
1264    /// Get repair footprints for a node in the current session.
1265    pub fn get_repair_footprints(
1266        &self,
1267        node_id: &str,
1268    ) -> Result<Vec<perspt_store::RepairFootprintRow>> {
1269        let session_id = self.session_id()?;
1270        self.store.get_repair_footprints(&session_id, node_id)
1271    }
1272
1273    /// Mark a repair footprint as resolved.
1274    pub fn resolve_repair_footprint(&self, footprint_id: &str) -> Result<()> {
1275        self.store.resolve_repair_footprint(footprint_id)?;
1276        log::debug!("Resolved repair footprint '{}'", footprint_id);
1277        Ok(())
1278    }
1279
1280    /// Record or update the budget envelope for the current session.
1281    pub fn upsert_budget_envelope(&self, budget: &perspt_core::BudgetEnvelope) -> Result<()> {
1282        let session_id = self.session_id()?;
1283        let row = perspt_store::BudgetEnvelopeRow {
1284            session_id,
1285            max_steps: budget.max_steps.map(|v| v as i32),
1286            steps_used: budget.steps_used as i32,
1287            max_revisions: budget.max_revisions.map(|v| v as i32),
1288            revisions_used: budget.revisions_used as i32,
1289            max_cost_usd: budget.max_cost_usd,
1290            cost_used_usd: budget.cost_used_usd,
1291        };
1292        self.store.upsert_budget_envelope(&row)?;
1293        log::debug!("Upserted budget envelope for session");
1294        Ok(())
1295    }
1296
1297    /// Get the budget envelope for the current session.
1298    pub fn get_budget_envelope(&self) -> Result<Option<perspt_store::BudgetEnvelopeRow>> {
1299        let session_id = self.session_id()?;
1300        self.store.get_budget_envelope(&session_id)
1301    }
1302}
1303
1304/// PSP-5 Phase 7: Aggregated review summary for a single node.
1305///
1306/// Consumed by both TUI review modal and CLI status/resume commands.
1307#[derive(Debug, Clone)]
1308pub struct NodeReviewSummary {
1309    pub node_id: String,
1310    pub latest_energy: Option<perspt_store::EnergyRecord>,
1311    pub energy_history: Vec<perspt_store::EnergyRecord>,
1312    pub attempt_count: u32,
1313    pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1314    pub sheaf_validations: Vec<perspt_store::SheafValidationRow>,
1315    pub interface_seals: Vec<perspt_store::InterfaceSealRow>,
1316    pub context_provenance: Vec<perspt_store::ContextProvenanceRecord>,
1317    pub branches: Vec<perspt_store::ProvisionalBranchRow>,
1318}
1319
1320/// PSP-5 Phase 7: Aggregated session-level review summary.
1321///
1322/// Consumed by both TUI dashboard and CLI status/resume commands.
1323#[derive(Debug, Clone)]
1324pub struct SessionReviewSummary {
1325    pub session_id: String,
1326    pub total_nodes: usize,
1327    pub completed: usize,
1328    pub failed: usize,
1329    pub escalated: usize,
1330    pub total_energy: f32,
1331    pub node_energies: Vec<(String, perspt_store::EnergyRecord)>,
1332    pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1333    pub branches_total: usize,
1334    pub active_branches: usize,
1335    pub sealed_branches: usize,
1336    pub merged_branches: usize,
1337    pub flushed_branches: usize,
1338    pub flush_decisions: Vec<perspt_store::BranchFlushRow>,
1339    /// Review audit: total decisions and breakdown
1340    pub review_total: usize,
1341    pub reviews_approved: usize,
1342    pub reviews_rejected: usize,
1343    pub reviews_corrected: usize,
1344}
1345
1346/// Ledger statistics (Legacy)
1347#[derive(Debug, Clone)]
1348pub struct LedgerStats {
1349    pub total_sessions: usize,
1350    pub total_commits: usize,
1351    pub db_size_bytes: u64,
1352}
1353
1354/// PSP-5 Phase 8: Per-node evidence bundle for session rehydration.
1355#[derive(Debug, Clone)]
1356pub struct NodeSnapshotDetail {
1357    pub record: NodeStateRecord,
1358    pub energy_history: Vec<perspt_store::EnergyRecord>,
1359    pub verification: Option<perspt_store::VerificationResultRow>,
1360    pub artifact_bundle: Option<perspt_store::ArtifactBundleRow>,
1361    pub sheaf_validations: Vec<perspt_store::SheafValidationRow>,
1362    pub interface_seals: Vec<perspt_store::InterfaceSealRow>,
1363    pub context_provenance: Option<perspt_store::ContextProvenanceRecord>,
1364}
1365
1366/// PSP-5 Phase 8: Complete session snapshot for resume/rehydration.
1367///
1368/// Aggregates all persisted state needed to reconstruct the orchestrator
1369/// DAG, restore node states, and resume execution from the last durable
1370/// boundary.
1371#[derive(Debug, Clone)]
1372pub struct SessionSnapshot {
1373    pub session_id: String,
1374    pub node_details: Vec<NodeSnapshotDetail>,
1375    pub graph_edges: Vec<perspt_store::TaskGraphEdgeRow>,
1376    pub branches: Vec<perspt_store::ProvisionalBranchRow>,
1377    pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1378    pub flushes: Vec<perspt_store::BranchFlushRow>,
1379}
1380
1381/// Generate a unique commit ID
1382fn generate_commit_id() -> String {
1383    use std::time::{SystemTime, UNIX_EPOCH};
1384    let now = SystemTime::now()
1385        .duration_since(UNIX_EPOCH)
1386        .unwrap()
1387        .as_nanos();
1388    format!("{:x}", now)
1389}
1390
1391/// Get current timestamp
1392fn chrono_timestamp() -> i64 {
1393    use std::time::{SystemTime, UNIX_EPOCH};
1394    SystemTime::now()
1395        .duration_since(UNIX_EPOCH)
1396        .unwrap()
1397        .as_secs() as i64
1398}
1399
1400/// ISO-8601 timestamp for committed_at fields
1401fn chrono_iso_now() -> String {
1402    use std::time::{SystemTime, UNIX_EPOCH};
1403    let secs = SystemTime::now()
1404        .duration_since(UNIX_EPOCH)
1405        .unwrap()
1406        .as_secs();
1407    // Simple UTC timestamp — YYYY-MM-DDTHH:MM:SSZ
1408    let days = secs / 86400;
1409    let time = secs % 86400;
1410    let h = time / 3600;
1411    let m = (time % 3600) / 60;
1412    let s = time % 60;
1413    // Days since 1970-01-01 to y/m/d (civil calendar)
1414    let (y, mo, d) = days_to_ymd(days);
1415    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, m, s)
1416}
1417
1418/// Convert days since Unix epoch to (year, month, day)
1419fn days_to_ymd(days: u64) -> (u64, u64, u64) {
1420    // Algorithm from Howard Hinnant's date library
1421    let z = days + 719468;
1422    let era = z / 146097;
1423    let doe = z - era * 146097;
1424    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1425    let y = yoe + era * 400;
1426    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1427    let mp = (5 * doy + 2) / 153;
1428    let d = doy - (153 * mp + 2) / 5 + 1;
1429    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1430    let y = if m <= 2 { y + 1 } else { y };
1431    (y, m, d)
1432}