Skip to main content

forge_engine/store/
db.rs

1use std::path::{Path, PathBuf};
2use std::sync::Mutex;
3use std::time::Instant;
4
5use rusqlite::{Connection, OptionalExtension};
6
7use crate::error::{ForgeError, ForgeResult};
8use crate::invariants;
9use crate::lab::evaluate::ScoreVector;
10use crate::lab::evidence::{
11    CausalHypothesis, ClaimStrength, EvidenceAssessment, ExperimentEvidenceBundle, VerificationPlan,
12};
13use crate::store::schema;
14
15const FORGE_LOCK_WARN_THRESHOLD_MS: u128 = 50;
16
17fn stats_from_persisted(alpha: f64, beta: f64, count: i64) -> cea_core::EdgeStats {
18    cea_core::EdgeStats {
19        alpha,
20        beta,
21        observations: count.max(0) as u64,
22    }
23}
24
25fn positive_stats(weight_delta: f64) -> cea_core::EdgeStats {
26    let mut stats = cea_core::EdgeStats::default();
27    stats.observe_positive(weight_delta);
28    stats
29}
30
31/// ForgeStore — SQLite-backed storage for all Forge state.
32///
33/// The connection is wrapped in a `Mutex` for thread-safety.
34pub struct ForgeStore {
35    conn: Mutex<Connection>,
36    path: PathBuf,
37}
38
39// Manual Debug impl to avoid requiring Debug on Mutex<Connection>
40impl std::fmt::Debug for ForgeStore {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("ForgeStore")
43            .field("path", &self.path)
44            .finish()
45    }
46}
47
48impl ForgeStore {
49    /// Open (or create) a Forge database at the given path.
50    ///
51    /// **Invariant I1**: Calls `invariants::refuse_to_open_db()` as the very first action.
52    /// If the file exists and is not a valid Forge DB, returns `ForgeError::RefuseToOpenDb`.
53    pub fn open(path: &Path) -> ForgeResult<Self> {
54        // FIRST ACTION: refuse-to-open check
55        invariants::refuse_to_open_db(path)?;
56
57        let is_new = !path.exists();
58
59        let conn = Connection::open(path)?;
60
61        // Enable WAL mode for better concurrent read performance
62        conn.pragma_update(None, "journal_mode", "wal")?;
63        // Set busy timeout to avoid SQLITE_BUSY under concurrent access
64        conn.pragma_update(None, "busy_timeout", "5000")?;
65
66        if is_new {
67            Self::run_initial_migration(&conn)?;
68        }
69
70        // Run additive migration v2 (idempotent — uses IF NOT EXISTS)
71        Self::run_migration_v2(&conn)?;
72        Self::run_migration_v3(&conn)?;
73        Self::run_migration_v4(&conn)?;
74        Self::run_migration_v5(&conn)?;
75
76        Ok(Self {
77            conn: Mutex::new(conn),
78            path: path.to_path_buf(),
79        })
80    }
81
82    /// Run the initial migration (migration 1) — creates all tables and indexes.
83    ///
84    /// Uses `schema::CREATE_STATEMENTS` as single source of truth to prevent
85    /// SQL divergence between the schema hash and actual DDL.
86    fn run_initial_migration(conn: &Connection) -> ForgeResult<()> {
87        conn.execute_batch(&format!(
88            "PRAGMA user_version = {user_version};",
89            user_version = schema::FORGE_CURRENT_USER_VERSION,
90        ))?;
91
92        // Execute all CREATE TABLE and CREATE INDEX statements from the
93        // single source of truth in schema.rs
94        for stmt in schema::CREATE_STATEMENTS {
95            conn.execute(stmt, [])?;
96        }
97
98        // Seed forge_meta
99        conn.execute_batch(&format!(
100            r#"
101INSERT INTO forge_meta VALUES ('schema_hash',    '{schema_hash}');
102INSERT INTO forge_meta VALUES ('schema_version', '1');
103INSERT INTO forge_meta VALUES ('created_at',     strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
104"#,
105            schema_hash = schema::forge_schema_hash(),
106        ))?;
107
108        Ok(())
109    }
110
111    /// Run migration v2 — additive tables for experiments, evidence, exports.
112    ///
113    /// This is idempotent: all statements use IF NOT EXISTS.
114    /// Historical v1 databases gain the new tables without data loss.
115    fn run_migration_v2(conn: &Connection) -> ForgeResult<()> {
116        for stmt in schema::MIGRATION_V2_STATEMENTS {
117            conn.execute(stmt, [])?;
118        }
119
120        // Update user_version if still at v1
121        let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
122        if current < schema::FORGE_V2_USER_VERSION {
123            conn.pragma_update(None, "user_version", schema::FORGE_V2_USER_VERSION)?;
124        }
125
126        Ok(())
127    }
128
129    fn run_migration_v3(conn: &Connection) -> ForgeResult<()> {
130        for stmt in schema::MIGRATION_V3_STATEMENTS {
131            conn.execute(stmt, [])?;
132        }
133
134        let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
135        if current < schema::FORGE_V3_USER_VERSION {
136            conn.pragma_update(None, "user_version", schema::FORGE_V3_USER_VERSION)?;
137        }
138
139        Ok(())
140    }
141
142    fn run_migration_v4(conn: &Connection) -> ForgeResult<()> {
143        let has_alpha = conn
144            .query_row(
145                "SELECT 1 FROM pragma_table_info('cea_edges') WHERE name = 'alpha' LIMIT 1",
146                [],
147                |_| Ok(()),
148            )
149            .optional()?
150            .is_some();
151        let has_beta = conn
152            .query_row(
153                "SELECT 1 FROM pragma_table_info('cea_edges') WHERE name = 'beta' LIMIT 1",
154                [],
155                |_| Ok(()),
156            )
157            .optional()?
158            .is_some();
159
160        if !has_alpha {
161            conn.execute(
162                "ALTER TABLE cea_edges ADD COLUMN alpha REAL NOT NULL DEFAULT 1.0",
163                [],
164            )?;
165        }
166        if !has_beta {
167            conn.execute(
168                "ALTER TABLE cea_edges ADD COLUMN beta REAL NOT NULL DEFAULT 1.0",
169                [],
170            )?;
171        }
172
173        let mut stmt = conn.prepare("SELECT edge_id, weight, count, alpha, beta FROM cea_edges")?;
174        let rows = stmt.query_map([], |row| {
175            Ok((
176                row.get::<_, String>(0)?,
177                row.get::<_, f64>(1)?,
178                row.get::<_, i64>(2)?,
179                row.get::<_, f64>(3)?,
180                row.get::<_, f64>(4)?,
181            ))
182        })?;
183
184        for row in rows {
185            let (edge_id, weight, count, alpha, beta) = row?;
186            let stats = if has_alpha && has_beta {
187                stats_from_persisted(alpha, beta, count)
188            } else {
189                cea_core::EdgeStats {
190                    alpha: 1.0 + weight.max(0.0),
191                    beta: 1.0,
192                    observations: count.max(0) as u64,
193                }
194            };
195            conn.execute(
196                "UPDATE cea_edges SET alpha = ?1, beta = ?2, confidence = ?3 WHERE edge_id = ?4",
197                rusqlite::params![stats.alpha, stats.beta, stats.confidence(), edge_id],
198            )?;
199        }
200
201        let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
202        if current < schema::FORGE_V4_USER_VERSION {
203            conn.pragma_update(None, "user_version", schema::FORGE_V4_USER_VERSION)?;
204        }
205
206        Ok(())
207    }
208
209    fn run_migration_v5(conn: &Connection) -> ForgeResult<()> {
210        let has_canonical_bundle_json = conn
211            .query_row(
212                "SELECT 1 FROM pragma_table_info('evidence_bundles') WHERE name = 'canonical_bundle_json' LIMIT 1",
213                [],
214                |_| Ok(()),
215            )
216            .optional()?
217            .is_some();
218
219        if !has_canonical_bundle_json {
220            conn.execute(
221                "ALTER TABLE evidence_bundles ADD COLUMN canonical_bundle_json TEXT",
222                [],
223            )?;
224        }
225
226        let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
227        if current < schema::FORGE_V5_USER_VERSION {
228            conn.pragma_update(None, "user_version", schema::FORGE_V5_USER_VERSION)?;
229        }
230
231        Ok(())
232    }
233
234    /// Acquire the connection lock. All public methods use this internally.
235    fn lock_conn(&self) -> ForgeResult<std::sync::MutexGuard<'_, Connection>> {
236        let started_at = Instant::now();
237        let guard = self
238            .conn
239            .lock()
240            .map_err(|e| ForgeError::Other(format!("ForgeStore lock poisoned: {e}")))?;
241        let waited_ms = started_at.elapsed().as_millis();
242        if waited_ms >= FORGE_LOCK_WARN_THRESHOLD_MS {
243            tracing::warn!(
244                path = %self.path.display(),
245                waited_ms,
246                threshold_ms = FORGE_LOCK_WARN_THRESHOLD_MS,
247                "ForgeStore connection lock acquisition exceeded threshold"
248            );
249        }
250        Ok(guard)
251    }
252
253    /// Execute a closure with transactional semantics.
254    pub fn with_transaction<F, T>(&self, f: F) -> ForgeResult<T>
255    where
256        F: FnOnce(&Connection) -> ForgeResult<T>,
257    {
258        let mut conn = self.lock_conn()?;
259        let tx = conn.transaction().map_err(ForgeError::Database)?;
260        let result = f(&tx)?;
261        tx.commit().map_err(ForgeError::Database)?;
262        Ok(result)
263    }
264
265    /// Execute a closure with a write transaction handle for consumers that can
266    /// execute transaction-owned CEA adapters without opening a nested tx.
267    pub fn with_transaction_conn<F, T>(&self, f: F) -> ForgeResult<T>
268    where
269        F: for<'tx> FnOnce(&rusqlite::Transaction<'tx>) -> ForgeResult<T>,
270    {
271        let mut conn = self.lock_conn()?;
272        let tx = conn.transaction()?;
273        let result = f(&tx)?;
274        tx.commit().map_err(ForgeError::Database)?;
275        Ok(result)
276    }
277
278    /// Execute a closure with the shared write-locked SQLite connection.
279    pub fn with_conn<F, T>(&self, f: F) -> ForgeResult<T>
280    where
281        F: FnOnce(&Connection) -> ForgeResult<T>,
282    {
283        let conn = self.lock_conn()?;
284        f(&conn)
285    }
286
287    /// Get the database path.
288    pub fn path(&self) -> &Path {
289        &self.path
290    }
291
292    // ── Candidate CRUD ──
293
294    pub fn insert_candidate(
295        &self,
296        candidate_id: &str,
297        spec_json: &str,
298        parents_json: &str,
299        status: &str,
300    ) -> ForgeResult<()> {
301        let now = chrono::Utc::now().to_rfc3339();
302        self.lock_conn()?.execute(
303            "INSERT INTO candidates (candidate_id, spec_json, parents_json, created_at, status) VALUES (?1, ?2, ?3, ?4, ?5)",
304            rusqlite::params![candidate_id, spec_json, parents_json, now, status],
305        )?;
306        Ok(())
307    }
308
309    pub fn get_candidate_spec(&self, candidate_id: &str) -> ForgeResult<String> {
310        self.lock_conn()?
311            .query_row(
312                "SELECT spec_json FROM candidates WHERE candidate_id = ?1",
313                [candidate_id],
314                |row| row.get(0),
315            )
316            .map_err(|_| ForgeError::NotFound(format!("candidate {candidate_id}")))
317    }
318
319    pub fn update_candidate_status(&self, candidate_id: &str, status: &str) -> ForgeResult<()> {
320        self.lock_conn()?.execute(
321            "UPDATE candidates SET status = ?1 WHERE candidate_id = ?2",
322            rusqlite::params![status, candidate_id],
323        )?;
324        Ok(())
325    }
326
327    // ── Task CRUD ──
328
329    pub fn insert_task(
330        &self,
331        task_id: &str,
332        suite_name: &str,
333        fixture_ref: &str,
334        prompt: &str,
335        constraints_json: &str,
336        weights_json: &str,
337    ) -> ForgeResult<()> {
338        let now = chrono::Utc::now().to_rfc3339();
339        self.lock_conn()?.execute(
340            "INSERT INTO tasks (task_id, suite_name, fixture_ref, prompt, constraints_json, weights_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
341            rusqlite::params![task_id, suite_name, fixture_ref, prompt, constraints_json, weights_json, now],
342        )?;
343        Ok(())
344    }
345
346    // ── EvalRun CRUD ──
347
348    #[allow(clippy::too_many_arguments)]
349    pub fn insert_eval_run(
350        &self,
351        eval_id: &str,
352        candidate_id: &str,
353        task_id: &str,
354        backend: &str,
355        seed: i64,
356        mindstate_hash: &str,
357        patch_hash: &str,
358        structural_sig: &str,
359        scores_json: &str,
360        violations_json: &str,
361        logs_ref: &str,
362        cea_run_hash: Option<&str>,
363    ) -> ForgeResult<()> {
364        let now = chrono::Utc::now().to_rfc3339();
365        self.lock_conn()?.execute(
366            "INSERT INTO eval_runs (eval_id, candidate_id, task_id, backend, seed, mindstate_hash, patch_hash, structural_sig, scores_json, violations_json, logs_ref, cea_run_hash, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
367            rusqlite::params![eval_id, candidate_id, task_id, backend, seed, mindstate_hash, patch_hash, structural_sig, scores_json, violations_json, logs_ref, cea_run_hash, now],
368        )?;
369        Ok(())
370    }
371
372    pub fn get_eval_runs_for_candidate(&self, candidate_id: &str) -> ForgeResult<Vec<EvalRunRow>> {
373        let conn = self.lock_conn()?;
374        let mut stmt = conn.prepare(
375            "SELECT eval_id, candidate_id, task_id, backend, seed, mindstate_hash, patch_hash, structural_sig, scores_json, violations_json, logs_ref, cea_run_hash, created_at FROM eval_runs WHERE candidate_id = ?1"
376        )?;
377        let rows = stmt.query_map([candidate_id], |row| {
378            Ok(EvalRunRow {
379                eval_id: row.get(0)?,
380                candidate_id: row.get(1)?,
381                task_id: row.get(2)?,
382                backend: row.get(3)?,
383                seed: row.get(4)?,
384                mindstate_hash: row.get(5)?,
385                patch_hash: row.get(6)?,
386                structural_sig: row.get(7)?,
387                scores_json: row.get(8)?,
388                violations_json: row.get(9)?,
389                logs_ref: row.get(10)?,
390                cea_run_hash: row.get(11)?,
391                created_at: row.get(12)?,
392            })
393        })?;
394        let mut result = Vec::new();
395        for row in rows {
396            result.push(row?);
397        }
398        Ok(result)
399    }
400
401    // ── Archive Cells ──
402
403    pub fn upsert_archive_cell(
404        &self,
405        cell_key: &str,
406        candidate_id: &str,
407        score_summary_json: &str,
408        cea_fingerprint: Option<&str>,
409    ) -> ForgeResult<()> {
410        let now = chrono::Utc::now().to_rfc3339();
411        self.lock_conn()?.execute(
412            "INSERT INTO archive_cells (cell_key, candidate_id, score_summary_json, cea_fingerprint, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(cell_key) DO UPDATE SET candidate_id = ?2, score_summary_json = ?3, cea_fingerprint = ?4, updated_at = ?5",
413            rusqlite::params![cell_key, candidate_id, score_summary_json, cea_fingerprint, now],
414        )?;
415        Ok(())
416    }
417
418    pub fn get_archive_cell(&self, cell_key: &str) -> ForgeResult<Option<ArchiveCellRow>> {
419        match self.lock_conn()?.query_row(
420            "SELECT cell_key, candidate_id, score_summary_json, cea_fingerprint, updated_at FROM archive_cells WHERE cell_key = ?1",
421            [cell_key],
422            |row| {
423                Ok(ArchiveCellRow {
424                    cell_key: row.get(0)?,
425                    candidate_id: row.get(1)?,
426                    score_summary_json: row.get(2)?,
427                    cea_fingerprint: row.get(3)?,
428                    updated_at: row.get(4)?,
429                })
430            },
431        ) {
432            Ok(r) => Ok(Some(r)),
433            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
434            Err(e) => Err(e.into()),
435        }
436    }
437
438    // ── Promotions ──
439
440    #[allow(clippy::too_many_arguments)]
441    pub fn insert_promotion(
442        &self,
443        version_id: &str,
444        candidate_id: &str,
445        frozen_spec_json: &str,
446        bounds_json: &str,
447        invariants_json: &str,
448        checksum: &str,
449        cea_fingerprint_json: Option<&str>,
450    ) -> ForgeResult<()> {
451        let now = chrono::Utc::now().to_rfc3339();
452        self.lock_conn()?.execute(
453            "INSERT INTO promotions (version_id, candidate_id, frozen_spec_json, bounds_json, invariants_json, checksum, cea_fingerprint_json, promoted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
454            rusqlite::params![version_id, candidate_id, frozen_spec_json, bounds_json, invariants_json, checksum, cea_fingerprint_json, now],
455        )?;
456        Ok(())
457    }
458
459    pub fn get_latest_promotion(&self) -> ForgeResult<Option<PromotionRow>> {
460        match self.lock_conn()?.query_row(
461            "SELECT version_id, candidate_id, frozen_spec_json, bounds_json, invariants_json, checksum, cea_fingerprint_json, promoted_at FROM promotions ORDER BY version_id DESC LIMIT 1",
462            [],
463            |row| {
464                Ok(PromotionRow {
465                    version_id: row.get(0)?,
466                    candidate_id: row.get(1)?,
467                    frozen_spec_json: row.get(2)?,
468                    bounds_json: row.get(3)?,
469                    invariants_json: row.get(4)?,
470                    checksum: row.get(5)?,
471                    cea_fingerprint_json: row.get(6)?,
472                    promoted_at: row.get(7)?,
473                })
474            },
475        ) {
476            Ok(r) => Ok(Some(r)),
477            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
478            Err(e) => Err(e.into()),
479        }
480    }
481
482    pub fn count_promotions(&self) -> ForgeResult<usize> {
483        let count: i64 =
484            self.lock_conn()?
485                .query_row("SELECT COUNT(*) FROM promotions", [], |row| row.get(0))?;
486        Ok(count as usize)
487    }
488
489    // ── Answer Traces ──
490
491    #[allow(clippy::too_many_arguments)]
492    pub fn insert_answer_trace(
493        &self,
494        trace_id: &str,
495        question_sig: &str,
496        version_id: &str,
497        strategy_tags_json: &str,
498        patch_hash: &str,
499        structural_sig: &str,
500        score_json: &str,
501    ) -> ForgeResult<()> {
502        let now = chrono::Utc::now().to_rfc3339();
503        self.lock_conn()?.execute(
504            "INSERT INTO answer_traces (trace_id, question_sig, version_id, strategy_tags_json, patch_hash, structural_sig, score_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
505            rusqlite::params![trace_id, question_sig, version_id, strategy_tags_json, patch_hash, structural_sig, score_json, now],
506        )?;
507        Ok(())
508    }
509
510    pub fn get_recent_traces_for_question(
511        &self,
512        question_sig: &str,
513        limit: usize,
514    ) -> ForgeResult<Vec<AnswerTraceRow>> {
515        let conn = self.lock_conn()?;
516        let mut stmt = conn.prepare(
517            "SELECT trace_id, question_sig, version_id, strategy_tags_json, patch_hash, structural_sig, score_json, created_at FROM answer_traces WHERE question_sig = ?1 ORDER BY created_at DESC LIMIT ?2",
518        )?;
519        let rows = stmt.query_map(rusqlite::params![question_sig, limit as i64], |row| {
520            Ok(AnswerTraceRow {
521                trace_id: row.get(0)?,
522                question_sig: row.get(1)?,
523                version_id: row.get(2)?,
524                strategy_tags_json: row.get(3)?,
525                patch_hash: row.get(4)?,
526                structural_sig: row.get(5)?,
527                score_json: row.get(6)?,
528                created_at: row.get(7)?,
529            })
530        })?;
531        let mut result = Vec::new();
532        for row in rows {
533            result.push(row?);
534        }
535        Ok(result)
536    }
537
538    // ── CEA CRUD ──
539
540    pub fn upsert_cea_node(
541        &self,
542        node_id: &str,
543        node_kind: &str,
544        sig_json: &str,
545    ) -> ForgeResult<()> {
546        let now = chrono::Utc::now().to_rfc3339();
547        self.lock_conn()?.execute(
548            "INSERT INTO cea_nodes (node_id, node_kind, sig_json, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?4) ON CONFLICT(node_id) DO UPDATE SET last_seen = ?4",
549            rusqlite::params![node_id, node_kind, sig_json, now],
550        )?;
551        Ok(())
552    }
553
554    pub fn upsert_cea_edge(
555        &self,
556        edge_id: &str,
557        cause_node_id: &str,
558        effect_node_id: &str,
559        weight_delta: f64,
560        version_id: &str,
561    ) -> ForgeResult<bool> {
562        let now = chrono::Utc::now().to_rfc3339();
563        let conn = self.lock_conn()?;
564        let weight_delta = weight_delta.max(0.0);
565        let existing = conn
566            .query_row(
567                "SELECT weight, count, alpha, beta FROM cea_edges WHERE cause_node_id = ?1 AND effect_node_id = ?2 AND version_id = ?3",
568                rusqlite::params![cause_node_id, effect_node_id, version_id],
569                |row| {
570                    Ok((
571                        row.get::<_, f64>(0)?,
572                        row.get::<_, i64>(1)?,
573                        row.get::<_, f64>(2)?,
574                        row.get::<_, f64>(3)?,
575                    ))
576                },
577            )
578            .optional()?;
579        if let Some((weight, count, alpha, beta)) = existing {
580            let mut stats = stats_from_persisted(alpha, beta, count);
581            stats.observe_positive(weight_delta);
582            conn.execute(
583                "UPDATE cea_edges SET weight = ?1, count = ?2, confidence = ?3, alpha = ?4, beta = ?5, last_seen = ?6 WHERE cause_node_id = ?7 AND effect_node_id = ?8 AND version_id = ?9",
584                rusqlite::params![
585                    weight + weight_delta,
586                    stats.observations as i64,
587                    stats.confidence(),
588                    stats.alpha,
589                    stats.beta,
590                    now,
591                    cause_node_id,
592                    effect_node_id,
593                    version_id
594                ],
595            )?;
596            return Ok(false);
597        }
598        let stats = positive_stats(weight_delta);
599        conn.execute(
600            "INSERT INTO cea_edges (edge_id, cause_node_id, effect_node_id, weight, count, confidence, alpha, beta, version_id, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
601            rusqlite::params![
602                edge_id,
603                cause_node_id,
604                effect_node_id,
605                weight_delta,
606                stats.observations as i64,
607                stats.confidence(),
608                stats.alpha,
609                stats.beta,
610                version_id,
611                now
612            ],
613        )?;
614        Ok(true)
615    }
616
617    pub fn check_cea_run_log(&self, run_hash: &str) -> ForgeResult<bool> {
618        let count: i64 = self.lock_conn()?.query_row(
619            "SELECT COUNT(*) FROM cea_run_log WHERE run_hash = ?1",
620            [run_hash],
621            |row| row.get(0),
622        )?;
623        Ok(count > 0)
624    }
625
626    pub fn insert_cea_run_log(
627        &self,
628        run_hash: &str,
629        eval_id: &str,
630        edges_added: i64,
631        edges_updated: i64,
632    ) -> ForgeResult<()> {
633        let now = chrono::Utc::now().to_rfc3339();
634        self.lock_conn()?.execute(
635            "INSERT INTO cea_run_log (run_hash, eval_id, edges_added, edges_updated, processed_at) VALUES (?1, ?2, ?3, ?4, ?5)",
636            rusqlite::params![run_hash, eval_id, edges_added, edges_updated, now],
637        )?;
638        Ok(())
639    }
640
641    pub fn get_cea_edges_for_cause(&self, cause_node_id: &str) -> ForgeResult<Vec<CeaEdgeRow>> {
642        let conn = self.lock_conn()?;
643        let mut stmt = conn.prepare(
644            "SELECT edge_id, cause_node_id, effect_node_id, weight, count, confidence, alpha, beta, version_id, last_seen FROM cea_edges WHERE cause_node_id = ?1",
645        )?;
646        let rows = stmt.query_map([cause_node_id], |row| {
647            let count: i64 = row.get(4)?;
648            let alpha: f64 = row.get(6)?;
649            let beta: f64 = row.get(7)?;
650            let stats = stats_from_persisted(alpha, beta, count);
651            Ok(CeaEdgeRow {
652                edge_id: row.get(0)?,
653                cause_node_id: row.get(1)?,
654                effect_node_id: row.get(2)?,
655                weight: row.get(3)?,
656                alpha,
657                beta,
658                count,
659                confidence: stats.confidence(),
660                version_id: row.get(8)?,
661                last_seen: row.get(9)?,
662            })
663        })?;
664        let mut result = Vec::new();
665        for row in rows {
666            result.push(row?);
667        }
668        Ok(result)
669    }
670
671    /// Get all CEA nodes and edges, optionally filtered by version_id.
672    pub fn get_all_cea_nodes_and_edges(
673        &self,
674        version_id: Option<&str>,
675    ) -> ForgeResult<(Vec<CeaNodeRow>, Vec<CeaEdgeRow>)> {
676        let conn = self.lock_conn()?;
677
678        // Nodes
679        let mut node_stmt = conn.prepare("SELECT node_id, node_kind, sig_json FROM cea_nodes")?;
680        let node_rows = node_stmt.query_map([], |row| {
681            Ok(CeaNodeRow {
682                node_id: row.get(0)?,
683                node_kind: row.get(1)?,
684                sig_json: row.get(2)?,
685            })
686        })?;
687        let mut nodes = Vec::new();
688        for row in node_rows {
689            nodes.push(row?);
690        }
691
692        // Edges
693        let edges = if let Some(vid) = version_id {
694            let mut edge_stmt = conn.prepare(
695                "SELECT edge_id, cause_node_id, effect_node_id, weight, count, confidence, alpha, beta, version_id, last_seen FROM cea_edges WHERE version_id = ?1",
696            )?;
697            let edge_rows = edge_stmt.query_map([vid], |row| {
698                let count: i64 = row.get(4)?;
699                let alpha: f64 = row.get(6)?;
700                let beta: f64 = row.get(7)?;
701                let stats = stats_from_persisted(alpha, beta, count);
702                Ok(CeaEdgeRow {
703                    edge_id: row.get(0)?,
704                    cause_node_id: row.get(1)?,
705                    effect_node_id: row.get(2)?,
706                    weight: row.get(3)?,
707                    alpha,
708                    beta,
709                    count,
710                    confidence: stats.confidence(),
711                    version_id: row.get(8)?,
712                    last_seen: row.get(9)?,
713                })
714            })?;
715            let mut result = Vec::new();
716            for row in edge_rows {
717                result.push(row?);
718            }
719            result
720        } else {
721            let mut edge_stmt = conn.prepare(
722                "SELECT edge_id, cause_node_id, effect_node_id, weight, count, confidence, alpha, beta, version_id, last_seen FROM cea_edges",
723            )?;
724            let edge_rows = edge_stmt.query_map([], |row| {
725                let count: i64 = row.get(4)?;
726                let alpha: f64 = row.get(6)?;
727                let beta: f64 = row.get(7)?;
728                let stats = stats_from_persisted(alpha, beta, count);
729                Ok(CeaEdgeRow {
730                    edge_id: row.get(0)?,
731                    cause_node_id: row.get(1)?,
732                    effect_node_id: row.get(2)?,
733                    weight: row.get(3)?,
734                    alpha,
735                    beta,
736                    count,
737                    confidence: stats.confidence(),
738                    version_id: row.get(8)?,
739                    last_seen: row.get(9)?,
740                })
741            })?;
742            let mut result = Vec::new();
743            for row in edge_rows {
744                result.push(row?);
745            }
746            result
747        };
748
749        Ok((nodes, edges))
750    }
751
752    pub fn get_cea_node_sig(&self, node_id: &str) -> ForgeResult<Option<String>> {
753        match self.lock_conn()?.query_row(
754            "SELECT sig_json FROM cea_nodes WHERE node_id = ?1",
755            [node_id],
756            |row| row.get(0),
757        ) {
758            Ok(s) => Ok(Some(s)),
759            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
760            Err(e) => Err(e.into()),
761        }
762    }
763
764    // ── Evidence Bundles (v2) ──
765
766    #[allow(clippy::too_many_arguments)]
767    pub fn insert_evidence_bundle(
768        &self,
769        bundle_id: &str,
770        candidate_id: &str,
771        eval_id: &str,
772        version_id: &str,
773        trace_id: &str,
774        scores_json: &str,
775        hypotheses_json: &str,
776        verification_plan_json: Option<&str>,
777        diff_json: Option<&str>,
778        assessment_json: Option<&str>,
779        warnings_json: &str,
780    ) -> ForgeResult<()> {
781        let now = chrono::Utc::now().to_rfc3339();
782        let canonical_bundle_json = canonical_bundle_json_from_legacy_parts(
783            bundle_id,
784            candidate_id,
785            eval_id,
786            version_id,
787            trace_id,
788            scores_json,
789            hypotheses_json,
790            verification_plan_json,
791            diff_json,
792            assessment_json,
793            warnings_json,
794            &now,
795        )?;
796        self.lock_conn()?.execute(
797            "INSERT OR REPLACE INTO evidence_bundles (bundle_id, candidate_id, eval_id, version_id, trace_id, canonical_bundle_json, scores_json, hypotheses_json, verification_plan_json, diff_json, assessment_json, warnings_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
798            rusqlite::params![bundle_id, candidate_id, eval_id, version_id, trace_id, canonical_bundle_json, scores_json, hypotheses_json, verification_plan_json, diff_json, assessment_json, warnings_json, now],
799        )?;
800        Ok(())
801    }
802
803    pub fn get_evidence_bundle(&self, bundle_id: &str) -> ForgeResult<Option<EvidenceBundleRow>> {
804        match self.lock_conn()?.query_row(
805            "SELECT bundle_id, candidate_id, eval_id, version_id, trace_id, canonical_bundle_json, scores_json, hypotheses_json, verification_plan_json, diff_json, assessment_json, warnings_json, created_at FROM evidence_bundles WHERE bundle_id = ?1",
806            [bundle_id],
807            |row| {
808                Ok(EvidenceBundleRow {
809                    bundle_id: row.get(0)?,
810                    candidate_id: row.get(1)?,
811                    eval_id: row.get(2)?,
812                    version_id: row.get(3)?,
813                    trace_id: row.get(4)?,
814                    canonical_bundle_json: row.get(5)?,
815                    scores_json: row.get(6)?,
816                    hypotheses_json: row.get(7)?,
817                    verification_plan_json: row.get(8)?,
818                    diff_json: row.get(9)?,
819                    assessment_json: row.get(10)?,
820                    warnings_json: row.get(11)?,
821                    created_at: row.get(12)?,
822                })
823            },
824        ) {
825            Ok(r) => Ok(Some(r)),
826            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
827            Err(e) => Err(e.into()),
828        }
829    }
830
831    pub fn get_latest_evidence_bundle_for_candidate(
832        &self,
833        candidate_id: &str,
834    ) -> ForgeResult<Option<EvidenceBundleRow>> {
835        match self.lock_conn()?.query_row(
836            "SELECT bundle_id, candidate_id, eval_id, version_id, trace_id, canonical_bundle_json, scores_json, hypotheses_json, verification_plan_json, diff_json, assessment_json, warnings_json, created_at
837             FROM evidence_bundles
838             WHERE candidate_id = ?1
839             ORDER BY created_at DESC, bundle_id DESC
840             LIMIT 1",
841            [candidate_id],
842            |row| {
843                Ok(EvidenceBundleRow {
844                    bundle_id: row.get(0)?,
845                    candidate_id: row.get(1)?,
846                    eval_id: row.get(2)?,
847                    version_id: row.get(3)?,
848                    trace_id: row.get(4)?,
849                    canonical_bundle_json: row.get(5)?,
850                    scores_json: row.get(6)?,
851                    hypotheses_json: row.get(7)?,
852                    verification_plan_json: row.get(8)?,
853                    diff_json: row.get(9)?,
854                    assessment_json: row.get(10)?,
855                    warnings_json: row.get(11)?,
856                    created_at: row.get(12)?,
857                })
858            },
859        ) {
860            Ok(r) => Ok(Some(r)),
861            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
862            Err(e) => Err(e.into()),
863        }
864    }
865
866    pub fn count_evidence_bundles_for_candidate(&self, candidate_id: &str) -> ForgeResult<usize> {
867        let count: i64 = self.lock_conn()?.query_row(
868            "SELECT COUNT(*) FROM evidence_bundles WHERE candidate_id = ?1",
869            [candidate_id],
870            |row| row.get(0),
871        )?;
872        Ok(count as usize)
873    }
874
875    pub fn count_evidence_bundles(&self) -> ForgeResult<usize> {
876        let count: i64 =
877            self.lock_conn()?
878                .query_row("SELECT COUNT(*) FROM evidence_bundles", [], |row| {
879                    row.get(0)
880                })?;
881        Ok(count as usize)
882    }
883
884    pub fn list_recent_evidence_bundle_ids(&self, limit: usize) -> ForgeResult<Vec<String>> {
885        let conn = self.lock_conn()?;
886        let mut stmt = conn.prepare(
887            "SELECT bundle_id
888             FROM evidence_bundles
889             ORDER BY created_at DESC, bundle_id DESC
890             LIMIT ?1",
891        )?;
892        let rows = stmt.query_map([limit as i64], |row| row.get::<_, String>(0))?;
893        let mut result = Vec::new();
894        for row in rows {
895            result.push(row?);
896        }
897        Ok(result)
898    }
899
900    // ── Experiment Runs (v2) ──
901
902    #[allow(clippy::too_many_arguments)]
903    pub fn insert_experiment_run(
904        &self,
905        run_id: &str,
906        candidate_id: &str,
907        task_id: &str,
908        trace_id: &str,
909        mode: &str,
910        baseline_json: &str,
911        patched_json: &str,
912        diff_json: &str,
913        baseline_descriptor_json: &str,
914        trials_json: &str,
915    ) -> ForgeResult<()> {
916        let now = chrono::Utc::now().to_rfc3339();
917        self.lock_conn()?.execute(
918            "INSERT INTO experiment_runs (run_id, candidate_id, task_id, trace_id, mode, baseline_json, patched_json, diff_json, baseline_descriptor_json, trials_json, completed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
919            rusqlite::params![run_id, candidate_id, task_id, trace_id, mode, baseline_json, patched_json, diff_json, baseline_descriptor_json, trials_json, now],
920        )?;
921        Ok(())
922    }
923
924    // ── Export Receipts (v2) ──
925
926    pub fn insert_export_receipt(
927        &self,
928        export_key: &str,
929        bundle_id: &str,
930        rendering_version: u32,
931        namespace: &str,
932        write_through_ok: Option<bool>,
933    ) -> ForgeResult<bool> {
934        let now = chrono::Utc::now().to_rfc3339();
935        let wt = write_through_ok.map(|b| if b { 1i64 } else { 0 });
936        let result = self.lock_conn()?.execute(
937            "INSERT OR IGNORE INTO export_receipts (export_key, bundle_id, rendering_version, namespace, write_through_ok, exported_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
938            rusqlite::params![export_key, bundle_id, rendering_version as i64, namespace, wt, now],
939        )?;
940        Ok(result > 0)
941    }
942
943    pub fn has_export_receipt(&self, export_key: &str) -> ForgeResult<bool> {
944        let count: i64 = self.lock_conn()?.query_row(
945            "SELECT COUNT(*) FROM export_receipts WHERE export_key = ?1",
946            [export_key],
947            |row| row.get(0),
948        )?;
949        Ok(count > 0)
950    }
951
952    // ── Run Failures (v2) ──
953
954    #[allow(clippy::too_many_arguments)]
955    pub fn insert_run_failure(
956        &self,
957        failure_id: &str,
958        run_id: &str,
959        class: &str,
960        message: &str,
961        phase: &str,
962        retriable: bool,
963        retry_count: u32,
964    ) -> ForgeResult<()> {
965        let now = chrono::Utc::now().to_rfc3339();
966        self.lock_conn()?.execute(
967            "INSERT INTO run_failures (failure_id, run_id, class, message, phase, retriable, retry_count, occurred_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
968            rusqlite::params![failure_id, run_id, class, message, phase, retriable as i64, retry_count as i64, now],
969        )?;
970        Ok(())
971    }
972
973    pub fn get_failures_for_run(&self, run_id: &str) -> ForgeResult<Vec<RunFailureRow>> {
974        let conn = self.lock_conn()?;
975        let mut stmt = conn.prepare(
976            "SELECT failure_id, run_id, class, message, phase, retriable, retry_count, occurred_at FROM run_failures WHERE run_id = ?1 ORDER BY occurred_at",
977        )?;
978        let rows = stmt.query_map([run_id], |row| {
979            Ok(RunFailureRow {
980                failure_id: row.get(0)?,
981                run_id: row.get(1)?,
982                class: row.get(2)?,
983                message: row.get(3)?,
984                phase: row.get(4)?,
985                retriable: row.get::<_, i64>(5)? != 0,
986                retry_count: row.get::<_, i64>(6)? as u32,
987                occurred_at: row.get(7)?,
988            })
989        })?;
990        let mut result = Vec::new();
991        for row in rows {
992            result.push(row?);
993        }
994        Ok(result)
995    }
996
997    // ── Verification Plans (v2) ──
998
999    pub fn insert_verification_plan(
1000        &self,
1001        plan_id: &str,
1002        bundle_id: &str,
1003        target_hypotheses_json: &str,
1004        steps_json: &str,
1005        policy_json: Option<&str>,
1006    ) -> ForgeResult<()> {
1007        let now = chrono::Utc::now().to_rfc3339();
1008        self.lock_conn()?.execute(
1009            "INSERT INTO verification_plans (plan_id, bundle_id, target_hypotheses_json, steps_json, policy_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1010            rusqlite::params![plan_id, bundle_id, target_hypotheses_json, steps_json, policy_json, now],
1011        )?;
1012        Ok(())
1013    }
1014
1015    // ── Tool Receipts (v3) ──
1016
1017    pub fn insert_tool_receipt(&self, row: &ToolReceiptRow) -> ForgeResult<bool> {
1018        let result = self.lock_conn()?.execute(
1019            "INSERT OR REPLACE INTO tool_receipts (
1020                receipt_id, tool_run_id, tool_name, tool_version, backend_kind,
1021                input_digest, output_digest_or_refs_json, policy_hash, approval_state,
1022                host_identity, started_at, finished_at, trace_id, trace_ctx_json,
1023                attempt_id, trial_id, error_class, retry_owner, replay_link,
1024                provider_call_id, raw_payload_json, recorded_at
1025            ) VALUES (
1026                ?1, ?2, ?3, ?4, ?5,
1027                ?6, ?7, ?8, ?9,
1028                ?10, ?11, ?12, ?13, ?14,
1029                ?15, ?16, ?17, ?18, ?19,
1030                ?20, ?21, ?22
1031            )",
1032            rusqlite::params![
1033                row.receipt_id,
1034                row.tool_run_id,
1035                row.tool_name,
1036                row.tool_version,
1037                row.backend_kind,
1038                row.input_digest,
1039                row.output_digest_or_refs_json,
1040                row.policy_hash,
1041                row.approval_state,
1042                row.host_identity,
1043                row.started_at,
1044                row.finished_at,
1045                row.trace_id,
1046                row.trace_ctx_json,
1047                row.attempt_id,
1048                row.trial_id,
1049                row.error_class,
1050                row.retry_owner,
1051                row.replay_link,
1052                row.provider_call_id,
1053                row.raw_payload_json,
1054                row.recorded_at,
1055            ],
1056        )?;
1057        Ok(result > 0)
1058    }
1059
1060    pub fn get_tool_receipt(&self, receipt_id: &str) -> ForgeResult<Option<ToolReceiptRow>> {
1061        match self.lock_conn()?.query_row(
1062            "SELECT receipt_id, tool_run_id, tool_name, tool_version, backend_kind,
1063                    input_digest, output_digest_or_refs_json, policy_hash, approval_state,
1064                    host_identity, started_at, finished_at, trace_id, trace_ctx_json,
1065                    attempt_id, trial_id, error_class, retry_owner, replay_link,
1066                    provider_call_id, raw_payload_json, recorded_at
1067             FROM tool_receipts
1068             WHERE receipt_id = ?1",
1069            [receipt_id],
1070            |row| {
1071                Ok(ToolReceiptRow {
1072                    receipt_id: row.get(0)?,
1073                    tool_run_id: row.get(1)?,
1074                    tool_name: row.get(2)?,
1075                    tool_version: row.get(3)?,
1076                    backend_kind: row.get(4)?,
1077                    input_digest: row.get(5)?,
1078                    output_digest_or_refs_json: row.get(6)?,
1079                    policy_hash: row.get(7)?,
1080                    approval_state: row.get(8)?,
1081                    host_identity: row.get(9)?,
1082                    started_at: row.get(10)?,
1083                    finished_at: row.get(11)?,
1084                    trace_id: row.get(12)?,
1085                    trace_ctx_json: row.get(13)?,
1086                    attempt_id: row.get(14)?,
1087                    trial_id: row.get(15)?,
1088                    error_class: row.get(16)?,
1089                    retry_owner: row.get(17)?,
1090                    replay_link: row.get(18)?,
1091                    provider_call_id: row.get(19)?,
1092                    raw_payload_json: row.get(20)?,
1093                    recorded_at: row.get(21)?,
1094                })
1095            },
1096        ) {
1097            Ok(row) => Ok(Some(row)),
1098            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1099            Err(err) => Err(err.into()),
1100        }
1101    }
1102}
1103
1104impl Drop for ForgeStore {
1105    fn drop(&mut self) {
1106        // LIB-019: Flush WAL on teardown to avoid stale journal files.
1107        match self.conn.lock() {
1108            Ok(conn) => {
1109                if let Err(e) = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)") {
1110                    tracing::warn!(error = %e, path = %self.path.display(), "ForgeStore: WAL checkpoint on drop failed");
1111                }
1112            }
1113            Err(e) => {
1114                tracing::warn!(error = %e, "ForgeStore: mutex poisoned during drop, skipping WAL checkpoint");
1115            }
1116        }
1117    }
1118}
1119
1120// ── Row types ──
1121
1122#[derive(Debug, Clone)]
1123pub struct EvalRunRow {
1124    pub eval_id: String,
1125    pub candidate_id: String,
1126    pub task_id: String,
1127    pub backend: String,
1128    pub seed: i64,
1129    pub mindstate_hash: String,
1130    pub patch_hash: String,
1131    pub structural_sig: String,
1132    pub scores_json: String,
1133    pub violations_json: String,
1134    pub logs_ref: String,
1135    pub cea_run_hash: Option<String>,
1136    pub created_at: String,
1137}
1138
1139#[derive(Debug, Clone)]
1140pub struct ArchiveCellRow {
1141    pub cell_key: String,
1142    pub candidate_id: String,
1143    pub score_summary_json: String,
1144    pub cea_fingerprint: Option<String>,
1145    pub updated_at: String,
1146}
1147
1148#[derive(Debug, Clone)]
1149pub struct PromotionRow {
1150    pub version_id: String,
1151    pub candidate_id: String,
1152    pub frozen_spec_json: String,
1153    pub bounds_json: String,
1154    pub invariants_json: String,
1155    pub checksum: String,
1156    pub cea_fingerprint_json: Option<String>,
1157    pub promoted_at: String,
1158}
1159
1160#[derive(Debug, Clone)]
1161pub struct AnswerTraceRow {
1162    pub trace_id: String,
1163    pub question_sig: String,
1164    pub version_id: String,
1165    pub strategy_tags_json: String,
1166    pub patch_hash: String,
1167    pub structural_sig: String,
1168    pub score_json: String,
1169    pub created_at: String,
1170}
1171
1172#[derive(Debug, Clone)]
1173pub struct CeaNodeRow {
1174    pub node_id: String,
1175    pub node_kind: String,
1176    pub sig_json: String,
1177}
1178
1179#[derive(Debug, Clone)]
1180pub struct CeaEdgeRow {
1181    pub edge_id: String,
1182    pub cause_node_id: String,
1183    pub effect_node_id: String,
1184    pub weight: f64,
1185    pub alpha: f64,
1186    pub beta: f64,
1187    pub count: i64,
1188    pub confidence: f64,
1189    pub version_id: String,
1190    pub last_seen: String,
1191}
1192
1193// ── v2 row types ──
1194
1195#[derive(Debug, Clone)]
1196pub struct EvidenceBundleRow {
1197    pub bundle_id: String,
1198    pub candidate_id: String,
1199    pub eval_id: String,
1200    pub version_id: String,
1201    pub trace_id: String,
1202    pub canonical_bundle_json: Option<String>,
1203    pub scores_json: String,
1204    pub hypotheses_json: String,
1205    pub verification_plan_json: Option<String>,
1206    pub diff_json: Option<String>,
1207    pub assessment_json: Option<String>,
1208    pub warnings_json: String,
1209    pub created_at: String,
1210}
1211
1212impl EvidenceBundleRow {
1213    pub fn canonical_bundle(&self) -> ForgeResult<semantic_memory_forge::EvidenceBundle> {
1214        if let Some(canonical_bundle_json) = &self.canonical_bundle_json {
1215            return Ok(serde_json::from_str(canonical_bundle_json)?);
1216        }
1217
1218        Ok(legacy_row_to_local_bundle(self)?.to_canonical_evidence_bundle())
1219    }
1220
1221    pub fn local_bundle(&self) -> ForgeResult<ExperimentEvidenceBundle> {
1222        let canonical = self.canonical_bundle()?;
1223        ExperimentEvidenceBundle::from_canonical_evidence_bundle(&canonical)
1224    }
1225}
1226
1227#[derive(Debug, Clone)]
1228pub struct RunFailureRow {
1229    pub failure_id: String,
1230    pub run_id: String,
1231    pub class: String,
1232    pub message: String,
1233    pub phase: String,
1234    pub retriable: bool,
1235    pub retry_count: u32,
1236    pub occurred_at: String,
1237}
1238
1239#[derive(Debug, Clone)]
1240pub struct ToolReceiptRow {
1241    pub receipt_id: String,
1242    pub tool_run_id: String,
1243    pub tool_name: String,
1244    pub tool_version: String,
1245    pub backend_kind: String,
1246    pub input_digest: String,
1247    pub output_digest_or_refs_json: String,
1248    pub policy_hash: String,
1249    pub approval_state: String,
1250    pub host_identity: String,
1251    pub started_at: String,
1252    pub finished_at: String,
1253    pub trace_id: String,
1254    pub trace_ctx_json: String,
1255    pub attempt_id: String,
1256    pub trial_id: String,
1257    pub error_class: Option<String>,
1258    pub retry_owner: String,
1259    pub replay_link: Option<String>,
1260    pub provider_call_id: Option<String>,
1261    pub raw_payload_json: String,
1262    pub recorded_at: String,
1263}
1264
1265#[allow(clippy::too_many_arguments)]
1266fn canonical_bundle_json_from_legacy_parts(
1267    bundle_id: &str,
1268    candidate_id: &str,
1269    eval_id: &str,
1270    version_id: &str,
1271    trace_id: &str,
1272    scores_json: &str,
1273    hypotheses_json: &str,
1274    verification_plan_json: Option<&str>,
1275    diff_json: Option<&str>,
1276    assessment_json: Option<&str>,
1277    warnings_json: &str,
1278    created_at: &str,
1279) -> ForgeResult<String> {
1280    Ok(serde_json::to_string(
1281        &legacy_bundle_from_parts(
1282            bundle_id,
1283            candidate_id,
1284            eval_id,
1285            version_id,
1286            trace_id,
1287            scores_json,
1288            hypotheses_json,
1289            verification_plan_json,
1290            diff_json,
1291            assessment_json,
1292            warnings_json,
1293            created_at,
1294        )?
1295        .to_canonical_evidence_bundle(),
1296    )?)
1297}
1298
1299fn legacy_row_to_local_bundle(row: &EvidenceBundleRow) -> ForgeResult<ExperimentEvidenceBundle> {
1300    legacy_bundle_from_parts(
1301        &row.bundle_id,
1302        &row.candidate_id,
1303        &row.eval_id,
1304        &row.version_id,
1305        &row.trace_id,
1306        &row.scores_json,
1307        &row.hypotheses_json,
1308        row.verification_plan_json.as_deref(),
1309        row.diff_json.as_deref(),
1310        row.assessment_json.as_deref(),
1311        &row.warnings_json,
1312        &row.created_at,
1313    )
1314}
1315
1316#[allow(clippy::too_many_arguments)]
1317fn legacy_bundle_from_parts(
1318    bundle_id: &str,
1319    candidate_id: &str,
1320    eval_id: &str,
1321    version_id: &str,
1322    trace_id: &str,
1323    scores_json: &str,
1324    hypotheses_json: &str,
1325    verification_plan_json: Option<&str>,
1326    diff_json: Option<&str>,
1327    assessment_json: Option<&str>,
1328    warnings_json: &str,
1329    created_at: &str,
1330) -> ForgeResult<ExperimentEvidenceBundle> {
1331    Ok(ExperimentEvidenceBundle {
1332        bundle_id: bundle_id.to_string(),
1333        candidate_id: candidate_id.to_string(),
1334        eval_id: eval_id.to_string(),
1335        version_id: version_id.to_string(),
1336        supersedes_claim_version_id: None,
1337        relation_lineage_hints: Default::default(),
1338        scores: parse_legacy_score_vector(scores_json)?,
1339        hypotheses: serde_json::from_str::<Vec<CausalHypothesis>>(hypotheses_json)?,
1340        verification: verification_plan_json
1341            .map(serde_json::from_str::<VerificationPlan>)
1342            .transpose()?,
1343        trace_id: Some(trace_id.to_string()),
1344        experiment_diff: parse_legacy_experiment_diff(diff_json),
1345        attribution_json: None,
1346        assessment: assessment_json
1347            .map(serde_json::from_str::<EvidenceAssessment>)
1348            .transpose()?,
1349        warnings: serde_json::from_str::<Vec<String>>(warnings_json)?,
1350        created_at: created_at.to_string(),
1351        run_id: None,
1352        attempt_id: None,
1353        causal_question: None,
1354        unit_definition: None,
1355        bundle_scope: None,
1356        pair_comparability: None,
1357        claim_strength: ClaimStrength::ProvisionalSinglePair,
1358        identification_rationale: None,
1359        known_threats: Vec::new(),
1360        patch_hash: None,
1361        treatment: None,
1362        outcome: None,
1363        covariates: None,
1364        promotion_state: None,
1365        primary_effect: None,
1366        all_effects: Vec::new(),
1367        hypothesis_edges: Vec::new(),
1368        receipts: Vec::new(),
1369        verification_trials: Vec::new(),
1370        refutation_artifacts: Vec::new(),
1371        sealed: false,
1372    })
1373}
1374
1375fn parse_legacy_score_vector(scores_json: &str) -> ForgeResult<ScoreVector> {
1376    let value: serde_json::Value = serde_json::from_str(scores_json)?;
1377    if let Ok(scores) = serde_json::from_value::<ScoreVector>(value.clone()) {
1378        return Ok(scores);
1379    }
1380
1381    let object = value.as_object().ok_or_else(|| {
1382        ForgeError::Other("legacy evidence scores must deserialize to a JSON object".into())
1383    })?;
1384
1385    Ok(ScoreVector {
1386        correctness: object
1387            .get("correctness")
1388            .and_then(serde_json::Value::as_f64)
1389            .unwrap_or(0.0),
1390        novelty: object
1391            .get("novelty")
1392            .and_then(serde_json::Value::as_f64)
1393            .unwrap_or(0.0),
1394        stability: object
1395            .get("stability")
1396            .and_then(serde_json::Value::as_f64)
1397            .unwrap_or(0.0),
1398        weighted_total: object
1399            .get("weighted_total")
1400            .and_then(serde_json::Value::as_f64)
1401            .unwrap_or(0.0),
1402        cea_confidence: object
1403            .get("cea_confidence")
1404            .and_then(serde_json::Value::as_f64),
1405        cea_predicted_correctness: object
1406            .get("cea_predicted_correctness")
1407            .and_then(serde_json::Value::as_f64),
1408    })
1409}
1410
1411fn parse_legacy_experiment_diff(
1412    diff_json: Option<&str>,
1413) -> Option<crate::experiment::ExperimentDiff> {
1414    diff_json.and_then(|diff_json| serde_json::from_str(diff_json).ok())
1415}