Skip to main content

microclaw_engine/internal/storage/db/learning/
skills.rs

1use super::*;
2
3#[derive(Debug, Clone, serde::Serialize)]
4pub struct SkillActivationRecord {
5    pub skill_name: String,
6    pub skill_version: Option<i64>,
7    pub activated_at: String,
8}
9
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct SkillLifecycleRecord {
12    pub skill_name: String,
13    pub state: String,
14    pub active_version: i64,
15    pub previous_trusted_version: Option<i64>,
16    pub source: String,
17    pub created_at: String,
18    pub updated_at: String,
19    pub state_reason: Option<String>,
20}
21
22#[derive(Debug, Clone, serde::Serialize)]
23pub struct SkillLearningSummary {
24    pub skill_name: String,
25    pub state: String,
26    pub active_version: i64,
27    pub total_outcomes: i64,
28    pub passed_outcomes: i64,
29    pub failed_outcomes: i64,
30    pub success_rate: f64,
31    pub utility_lower_bound: f64,
32    pub last_outcome_at: Option<String>,
33}
34
35#[derive(Debug, Clone, serde::Serialize)]
36pub struct SkillTaskUtilitySummary {
37    pub skill_name: String,
38    pub skill_version: i64,
39    pub task_type: String,
40    pub task_family: String,
41    pub total_outcomes: i64,
42    pub passed_outcomes: i64,
43    pub failed_outcomes: i64,
44    pub success_rate: f64,
45    pub utility_lower_bound: f64,
46}
47
48#[derive(Debug, Clone, serde::Serialize)]
49pub struct SkillFailurePattern {
50    pub pattern_id: String,
51    pub skill_name: String,
52    pub skill_version: i64,
53    pub task_type: String,
54    pub task_family: String,
55    pub environment_fingerprint: Option<String>,
56    pub tool_name: Option<String>,
57    pub error_category: String,
58    pub failure_count: i64,
59    pub recovery_successes: i64,
60    pub state: String,
61    pub cooldown_until: Option<String>,
62    pub last_evidence: Option<String>,
63    pub updated_at: String,
64}
65
66#[derive(Debug, Clone, serde::Serialize)]
67pub struct LearningClaim {
68    pub claim_id: String,
69    pub comparison_id: String,
70    pub skill_name: String,
71    pub base_version: i64,
72    pub claim_version: i64,
73    pub statement: String,
74    pub applicability: serde_json::Value,
75    pub confidence: f64,
76    pub evidence: serde_json::Value,
77    pub counterexamples: serde_json::Value,
78    pub status: String,
79    pub supersedes_claim_id: Option<String>,
80    pub created_at: String,
81    pub updated_at: String,
82}
83
84#[derive(Debug, Clone, serde::Serialize)]
85pub struct SkillCandidate {
86    pub candidate_id: String,
87    pub claim_id: String,
88    pub skill_name: String,
89    pub base_version: i64,
90    pub candidate_version: i64,
91    pub content: String,
92    pub content_hash: String,
93    pub status: String,
94    pub created_at: String,
95    pub updated_at: String,
96}
97
98#[derive(Debug, Clone, serde::Serialize)]
99pub struct ShadowEvaluation {
100    pub evaluation_id: String,
101    pub candidate_id: String,
102    pub sample_count: i64,
103    pub baseline_passed: i64,
104    pub candidate_passed: i64,
105    pub baseline_utility_lower_bound: f64,
106    pub candidate_utility_lower_bound: f64,
107    pub baseline_cost_usd: f64,
108    pub candidate_cost_usd: f64,
109    pub baseline_duration_ms: i64,
110    pub candidate_duration_ms: i64,
111    pub regression_count: i64,
112    pub verdict: String,
113    pub reason: String,
114    pub updated_at: String,
115}
116
117#[derive(Debug, Clone, serde::Serialize)]
118pub struct LearningJournalEvent {
119    pub id: i64,
120    pub event_type: String,
121    pub entity_type: String,
122    pub entity_id: String,
123    pub summary: String,
124    pub evidence: serde_json::Value,
125    pub undo_action: Option<String>,
126    pub created_at: String,
127}
128
129#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
130#[serde(default)]
131pub struct SkillGovernancePolicy {
132    pub candidate_failures_to_degrade: i64,
133    pub trial_min_outcomes: i64,
134    pub trial_promote_rate: f64,
135    pub trial_degrade_rate: f64,
136    pub trusted_min_outcomes: i64,
137    pub trusted_degrade_rate: f64,
138    pub utility_confidence_z: f64,
139    pub trial_promote_utility_lower_bound: f64,
140    pub failure_pattern_min_failures: i64,
141    pub failure_pattern_cooldown_hours: i64,
142    pub failure_pattern_recovery_successes: i64,
143    pub shadow_min_samples: i64,
144    pub shadow_promote_utility_margin: f64,
145    pub shadow_max_cost_ratio: f64,
146    pub shadow_max_regressions: i64,
147}
148
149impl Default for SkillGovernancePolicy {
150    fn default() -> Self {
151        Self {
152            candidate_failures_to_degrade: 2,
153            trial_min_outcomes: 3,
154            trial_promote_rate: 0.8,
155            trial_degrade_rate: 0.5,
156            trusted_min_outcomes: 5,
157            trusted_degrade_rate: 0.6,
158            utility_confidence_z: 1.96,
159            trial_promote_utility_lower_bound: 0.4,
160            failure_pattern_min_failures: 2,
161            failure_pattern_cooldown_hours: 24,
162            failure_pattern_recovery_successes: 2,
163            shadow_min_samples: 3,
164            shadow_promote_utility_margin: 0.05,
165            shadow_max_cost_ratio: 1.2,
166            shadow_max_regressions: 0,
167        }
168    }
169}
170
171#[derive(Debug, Clone, serde::Serialize)]
172pub struct SkillApplicability {
173    pub environment_fingerprint: Option<String>,
174    pub task_type: Option<String>,
175    pub task_family: Option<String>,
176    pub matching_outcomes: i64,
177    pub passed_outcomes: i64,
178    pub failed_outcomes: i64,
179    pub success_rate: Option<f64>,
180    pub utility_lower_bound: Option<f64>,
181    pub allowed: bool,
182    pub reason: String,
183}
184
185pub(crate) fn validate_skill_governance_policy(
186    policy: &SkillGovernancePolicy,
187) -> Result<(), MicroClawError> {
188    if policy.candidate_failures_to_degrade < 1
189        || policy.trial_min_outcomes < 2
190        || policy.trusted_min_outcomes < policy.trial_min_outcomes
191        || !(0.0..=1.0).contains(&policy.trial_promote_rate)
192        || !(0.0..=1.0).contains(&policy.trial_degrade_rate)
193        || !(0.0..=1.0).contains(&policy.trusted_degrade_rate)
194        || !(0.0..=1.0).contains(&policy.trial_promote_utility_lower_bound)
195        || !policy.utility_confidence_z.is_finite()
196        || !(0.0..=5.0).contains(&policy.utility_confidence_z)
197        || policy.failure_pattern_min_failures < 1
198        || policy.failure_pattern_cooldown_hours < 1
199        || policy.failure_pattern_recovery_successes < 1
200        || policy.shadow_min_samples < 1
201        || !policy.shadow_promote_utility_margin.is_finite()
202        || !(0.0..=1.0).contains(&policy.shadow_promote_utility_margin)
203        || !policy.shadow_max_cost_ratio.is_finite()
204        || policy.shadow_max_cost_ratio < 1.0
205        || policy.shadow_max_regressions < 0
206        || policy.trial_degrade_rate >= policy.trial_promote_rate
207    {
208        return Err(MicroClawError::ToolExecution(
209            "invalid skill governance policy: counts, rates, confidence, or utility lower bound"
210                .into(),
211        ));
212    }
213    Ok(())
214}
215
216pub(crate) fn sync_skill_outcomes_for_run(
217    tx: &Transaction<'_>,
218    run_id: &str,
219) -> Result<(), MicroClawError> {
220    let now = chrono::Utc::now().to_rfc3339();
221    let mut verifier_stmt = tx.prepare(
222        "SELECT verifier_type, verifier_name, verdict, confidence, evidence, valid_until
223         FROM verifier_results
224         WHERE run_id=?1 AND (valid_until IS NULL OR valid_until > ?2)",
225    )?;
226    let verifier_rows = verifier_stmt.query_map(params![run_id, now], |row| {
227        Ok((
228            row.get::<_, String>(0)?,
229            row.get::<_, String>(1)?,
230            row.get::<_, String>(2)?,
231            row.get::<_, f64>(3)?,
232            row.get::<_, Option<String>>(4)?,
233            row.get::<_, Option<String>>(5)?,
234        ))
235    })?;
236    let mut strongest: Vec<VerifierCandidate> = Vec::new();
237    let mut strongest_priority = -1;
238    for row in verifier_rows {
239        let candidate = row?;
240        let candidate_priority = verifier_priority(&candidate.0);
241        if candidate_priority > strongest_priority {
242            strongest.clear();
243            strongest.push(candidate);
244            strongest_priority = candidate_priority;
245        } else if candidate_priority == strongest_priority {
246            strongest.push(candidate);
247        }
248    }
249    drop(verifier_stmt);
250    if strongest.is_empty() {
251        tx.execute(
252            "DELETE FROM skill_outcomes WHERE run_id=?1",
253            params![run_id],
254        )?;
255        return Ok(());
256    }
257    let verifier_type = strongest[0].0.clone();
258    let (verifier_name, verdict, confidence, evidence) = if verifier_type == "human"
259        && strongest.len() > 1
260    {
261        let passed_weight: f64 = strongest
262            .iter()
263            .filter(|result| result.2 == "passed")
264            .map(|result| result.3)
265            .sum();
266        let failed_weight: f64 = strongest
267            .iter()
268            .filter(|result| result.2 == "failed")
269            .map(|result| result.3)
270            .sum();
271        let total_weight = passed_weight + failed_weight;
272        let verdict = if passed_weight > failed_weight {
273            "passed"
274        } else {
275            "failed"
276        };
277        let confidence = if total_weight > 0.0 {
278            passed_weight.max(failed_weight) / total_weight
279        } else {
280            0.0
281        };
282        (
283            "human_consensus".to_string(),
284            verdict.to_string(),
285            confidence,
286            Some(format!(
287                "{} active human feedback item(s): passed_weight={passed_weight:.3}, failed_weight={failed_weight:.3}",
288                strongest.len()
289            )),
290        )
291    } else {
292        // Independent deterministic/environmental/rule checks are
293        // conjunctive: one failure is enough to reject the outcome.
294        let selected = strongest
295            .iter()
296            .filter(|result| result.2 == "failed")
297            .max_by(|left, right| left.3.total_cmp(&right.3))
298            .or_else(|| {
299                strongest
300                    .iter()
301                    .max_by(|left, right| left.3.total_cmp(&right.3))
302            })
303            .expect("strongest verifier group is non-empty");
304        (
305            selected.1.clone(),
306            selected.2.clone(),
307            selected.3,
308            selected.4.clone(),
309        )
310    };
311    let valid_until = strongest.iter().filter_map(|result| result.5.clone()).min();
312
313    let mut activation_stmt = tx.prepare(
314        "SELECT DISTINCT skill_name, COALESCE(skill_version, 0)
315         FROM skill_activation_logs
316         WHERE experience_run_id=?1",
317    )?;
318    let activation_rows = activation_stmt.query_map(params![run_id], |row| {
319        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
320    })?;
321    let activations = activation_rows.collect::<Result<Vec<_>, _>>()?;
322    drop(activation_stmt);
323    let attribution_confidence = if activations.is_empty() {
324        0.0
325    } else {
326        1.0 / activations.len() as f64
327    };
328    for (skill_name, mut skill_version) in activations {
329        if skill_version == 0 {
330            skill_version = tx
331                .query_row(
332                    "SELECT active_version FROM skill_lifecycle WHERE skill_name=?1",
333                    params![skill_name],
334                    |row| row.get(0),
335                )
336                .optional()?
337                .unwrap_or(0);
338        }
339        if skill_version == 0 {
340            continue;
341        }
342        tx.execute(
343            "INSERT INTO skill_outcomes(
344                skill_name, skill_version, run_id, verdict, verifier_type,
345                verifier_name, confidence, evidence, valid_until,
346                attribution_confidence, created_at, updated_at
347             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
348             ON CONFLICT(skill_name, run_id) DO UPDATE SET
349                skill_version=excluded.skill_version,
350                verdict=excluded.verdict,
351                verifier_type=excluded.verifier_type,
352                verifier_name=excluded.verifier_name,
353                confidence=excluded.confidence,
354                evidence=excluded.evidence,
355                valid_until=excluded.valid_until,
356                attribution_confidence=excluded.attribution_confidence,
357                updated_at=excluded.updated_at",
358            params![
359                skill_name,
360                skill_version,
361                run_id,
362                verdict,
363                verifier_type,
364                verifier_name,
365                confidence,
366                evidence,
367                valid_until,
368                attribution_confidence,
369                now
370            ],
371        )?;
372        // Runtime completion and model self-evaluation remain observable but
373        // cannot promote, degrade, or contraindicate reusable behavior.
374        if verifier_can_govern_skill(&verifier_type) && attribution_confidence >= 0.999 {
375            evaluate_skill_lifecycle(tx, &skill_name, skill_version)?;
376        }
377    }
378    Ok(())
379}
380
381pub(crate) fn classify_failure_category(evidence: Option<&str>) -> &'static str {
382    let value = evidence.unwrap_or("").to_ascii_lowercase();
383    if value.contains("timeout") || value.contains("timed out") {
384        "timeout"
385    } else if value.contains("permission")
386        || value.contains("denied")
387        || value.contains("forbidden")
388    {
389        "permission"
390    } else if value.contains("not found") || value.contains("missing") {
391        "dependency"
392    } else if value.contains("network") || value.contains("connect") || value.contains("dns") {
393        "network"
394    } else if value.contains("invalid") || value.contains("parse") || value.contains("syntax") {
395        "invalid_input"
396    } else if value.contains("cancel") {
397        "cancelled"
398    } else {
399        "execution"
400    }
401}
402
403pub(crate) fn sync_skill_failure_patterns_for_run(
404    tx: &Transaction<'_>,
405    run_id: &str,
406) -> Result<(), MicroClawError> {
407    // Re-project this run idempotently. This is also required when human
408    // feedback changes or verifier evidence expires.
409    let prior_patterns = {
410        let mut stmt =
411            tx.prepare("SELECT pattern_id FROM skill_failure_pattern_evidence WHERE run_id=?1")?;
412        let rows = stmt
413            .query_map(params![run_id], |row| row.get::<_, String>(0))?
414            .collect::<Result<Vec<_>, _>>()?;
415        rows
416    };
417    tx.execute(
418        "DELETE FROM skill_failure_pattern_evidence WHERE run_id=?1",
419        params![run_id],
420    )?;
421    for pattern_id in prior_patterns {
422        let (failures, recoveries): (i64, i64) = tx.query_row(
423            "SELECT
424                COALESCE(SUM(CASE WHEN verdict='failed' THEN 1 ELSE 0 END),0),
425                COALESCE(SUM(CASE WHEN verdict='passed' THEN 1 ELSE 0 END),0)
426             FROM skill_failure_pattern_evidence WHERE pattern_id=?1",
427            params![pattern_id],
428            |row| Ok((row.get(0)?, row.get(1)?)),
429        )?;
430        if failures == 0 {
431            tx.execute(
432                "DELETE FROM skill_failure_patterns WHERE pattern_id=?1",
433                params![pattern_id],
434            )?;
435        } else {
436            tx.execute(
437                "UPDATE skill_failure_patterns
438                 SET failure_count=?2, recovery_successes=?3,
439                     state=CASE WHEN ?2 < (
440                       SELECT failure_pattern_min_failures
441                       FROM skill_governance_policy WHERE singleton_id=1
442                     ) THEN 'observed' ELSE state END,
443                     cooldown_until=CASE WHEN ?2 < (
444                       SELECT failure_pattern_min_failures
445                       FROM skill_governance_policy WHERE singleton_id=1
446                     ) THEN NULL ELSE cooldown_until END,
447                     updated_at=?4
448                 WHERE pattern_id=?1",
449                params![
450                    pattern_id,
451                    failures,
452                    recoveries,
453                    chrono::Utc::now().to_rfc3339()
454                ],
455            )?;
456        }
457    }
458    let context: Option<(String, String, Option<String>)> = tx
459        .query_row(
460            "SELECT task_type, task_family, environment_fingerprint
461             FROM experience_runs WHERE run_id=?1",
462            params![run_id],
463            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
464        )
465        .optional()?;
466    let Some((task_type, task_family, environment)) = context else {
467        return Ok(());
468    };
469    let policy: (i64, i64, i64) = tx.query_row(
470        "SELECT failure_pattern_min_failures, failure_pattern_cooldown_hours,
471                failure_pattern_recovery_successes
472         FROM skill_governance_policy WHERE singleton_id=1",
473        [],
474        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
475    )?;
476    let skills = {
477        let mut stmt = tx.prepare(
478            "SELECT skill_name, skill_version, verdict, evidence
479             FROM skill_outcomes
480             WHERE run_id=?1 AND attribution_confidence >= 0.999
481               AND verifier_type IN ('deterministic','environmental','human','rule_based')",
482        )?;
483        let rows = stmt
484            .query_map(params![run_id], |row| {
485                Ok((
486                    row.get::<_, String>(0)?,
487                    row.get::<_, i64>(1)?,
488                    row.get::<_, String>(2)?,
489                    row.get::<_, Option<String>>(3)?,
490                ))
491            })?
492            .collect::<Result<Vec<_>, _>>()?;
493        rows
494    };
495    let failed_tools = {
496        let mut stmt = tx.prepare(
497            "SELECT json_extract(payload_json, '$.tool_name'), evidence
498             FROM outcome_envelopes
499             WHERE run_id=?1 AND verdict='failed' AND scope='tool_result'",
500        )?;
501        let rows = stmt
502            .query_map(params![run_id], |row| {
503                Ok((
504                    row.get::<_, Option<String>>(0)?,
505                    row.get::<_, Option<String>>(1)?,
506                ))
507            })?
508            .collect::<Result<Vec<_>, _>>()?;
509        rows
510    };
511    let now = chrono::Utc::now();
512    for (skill_name, skill_version, verdict, skill_evidence) in skills {
513        if verdict == "failed" {
514            let evidence_items = if failed_tools.is_empty() {
515                vec![(None, skill_evidence.clone())]
516            } else {
517                failed_tools.clone()
518            };
519            for (tool_name, evidence) in evidence_items {
520                let category = classify_failure_category(evidence.as_deref());
521                let canonical = format!(
522                    "{skill_name}\n{skill_version}\n{task_family}\n{}\n{}\n{category}",
523                    environment.as_deref().unwrap_or(""),
524                    tool_name.as_deref().unwrap_or("")
525                );
526                use sha2::{Digest, Sha256};
527                let pattern_id = to_hex(&Sha256::digest(canonical.as_bytes()));
528                let cooldown_until = (now + chrono::Duration::hours(policy.1)).to_rfc3339();
529                tx.execute(
530                    "INSERT INTO skill_failure_patterns(
531                        pattern_id, skill_name, skill_version, task_type, task_family,
532                        environment_fingerprint, tool_name, error_category,
533                        failure_count, recovery_successes, state, cooldown_until,
534                        last_evidence, first_seen_at, updated_at
535                     ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,0,0,'observed',NULL,?9,?10,?10)
536                     ON CONFLICT(pattern_id) DO UPDATE SET
537                        last_evidence=excluded.last_evidence,
538                        updated_at=excluded.updated_at",
539                    params![
540                        pattern_id,
541                        skill_name,
542                        skill_version,
543                        task_type,
544                        task_family,
545                        environment,
546                        tool_name,
547                        category,
548                        evidence,
549                        now.to_rfc3339()
550                    ],
551                )?;
552                let inserted = tx.execute(
553                    "INSERT OR IGNORE INTO skill_failure_pattern_evidence(
554                        pattern_id, run_id, verdict, created_at
555                     ) VALUES (?1,?2,'failed',?3)",
556                    params![pattern_id, run_id, now.to_rfc3339()],
557                )?;
558                if inserted > 0 {
559                    tx.execute(
560                        "UPDATE skill_failure_patterns
561                         SET failure_count=failure_count+1,
562                             recovery_successes=0,
563                             state=CASE WHEN failure_count+1 >= ?2
564                                        THEN 'active' ELSE 'observed' END,
565                             cooldown_until=CASE WHEN failure_count+1 >= ?2
566                                        THEN ?3 ELSE cooldown_until END,
567                             updated_at=?4 WHERE pattern_id=?1",
568                        params![pattern_id, policy.0, cooldown_until, now.to_rfc3339()],
569                    )?;
570                    if policy.0 <= {
571                        tx.query_row(
572                            "SELECT failure_count FROM skill_failure_patterns
573                             WHERE pattern_id=?1",
574                            params![pattern_id],
575                            |row| row.get::<_, i64>(0),
576                        )?
577                    } {
578                        let current: Option<(String, Option<i64>)> = tx
579                            .query_row(
580                                "SELECT state, previous_trusted_version FROM skill_lifecycle
581                                 WHERE skill_name=?1 AND active_version=?2",
582                                params![skill_name, skill_version],
583                                |row| Ok((row.get(0)?, row.get(1)?)),
584                            )
585                            .optional()?;
586                        if current
587                            .as_ref()
588                            .is_some_and(|(state, _)| state != "degraded" && state != "archived")
589                        {
590                            let (from_state, previous_trusted) =
591                                current.unwrap_or_else(|| (String::new(), None));
592                            let reason = format!(
593                                "active failure pattern: task_family={task_family}, error_category={category}"
594                            );
595                            if from_state == "trusted" && previous_trusted.is_some() {
596                                let restored = previous_trusted.unwrap_or_default();
597                                let rollback_reason = format!(
598                                    "automatic rollback from v{skill_version} to v{restored}: {reason}"
599                                );
600                                tx.execute(
601                                    "UPDATE skill_lifecycle SET state='trusted',
602                                     active_version=?2, previous_trusted_version=NULL,
603                                     state_reason=?3, updated_at=?4 WHERE skill_name=?1",
604                                    params![
605                                        skill_name,
606                                        restored,
607                                        rollback_reason,
608                                        now.to_rfc3339()
609                                    ],
610                                )?;
611                                tx.execute(
612                                    "UPDATE skill_candidates
613                                     SET status='rolled_back', updated_at=?3
614                                     WHERE skill_name=?1 AND candidate_version=?2
615                                       AND status='promoted'",
616                                    params![skill_name, skill_version, now.to_rfc3339()],
617                                )?;
618                                tx.execute(
619                                    "INSERT INTO skill_lifecycle_events(
620                                        skill_name, from_state, to_state, version,
621                                        reason, created_at
622                                     ) VALUES (?1,'trusted','trusted',?2,?3,?4)",
623                                    params![
624                                        skill_name,
625                                        restored,
626                                        rollback_reason,
627                                        now.to_rfc3339()
628                                    ],
629                                )?;
630                                tx.execute(
631                                    "INSERT INTO learning_journal_events(
632                                        event_type, entity_type, entity_id, summary,
633                                        evidence_json, created_at
634                                     ) VALUES ('automatic_rollback','skill',?1,?2,?3,?4)",
635                                    params![
636                                        skill_name,
637                                        rollback_reason,
638                                        serde_json::json!({
639                                            "failed_version": skill_version,
640                                            "restored_version": restored,
641                                            "trigger": reason
642                                        })
643                                        .to_string(),
644                                        now.to_rfc3339()
645                                    ],
646                                )?;
647                            } else {
648                                tx.execute(
649                                    "UPDATE skill_lifecycle SET state='degraded',
650                                     previous_trusted_version=CASE WHEN state='trusted'
651                                        THEN active_version ELSE previous_trusted_version END,
652                                     state_reason=?3, updated_at=?4
653                                     WHERE skill_name=?1 AND active_version=?2",
654                                    params![skill_name, skill_version, reason, now.to_rfc3339()],
655                                )?;
656                                tx.execute(
657                                    "INSERT INTO skill_lifecycle_events(
658                                        skill_name, from_state, to_state, version,
659                                        reason, created_at
660                                     ) VALUES (?1,?2,'degraded',?3,?4,?5)",
661                                    params![
662                                        skill_name,
663                                        from_state,
664                                        skill_version,
665                                        reason,
666                                        now.to_rfc3339()
667                                    ],
668                                )?;
669                            }
670                        }
671                    }
672                }
673            }
674        } else if verdict == "passed" {
675            let patterns = {
676                let mut stmt = tx.prepare(
677                    "SELECT pattern_id FROM skill_failure_patterns
678                     WHERE skill_name=?1 AND skill_version=?2 AND task_family=?3
679                       AND (environment_fingerprint IS NULL OR environment_fingerprint=?4)
680                       AND state IN ('active','cooldown','trial')",
681                )?;
682                let rows = stmt
683                    .query_map(
684                        params![skill_name, skill_version, task_family, environment],
685                        |row| row.get::<_, String>(0),
686                    )?
687                    .collect::<Result<Vec<_>, _>>()?;
688                rows
689            };
690            for pattern_id in patterns {
691                let inserted = tx.execute(
692                    "INSERT OR IGNORE INTO skill_failure_pattern_evidence(
693                        pattern_id, run_id, verdict, created_at
694                     ) VALUES (?1,?2,'passed',?3)",
695                    params![pattern_id, run_id, now.to_rfc3339()],
696                )?;
697                if inserted > 0 {
698                    tx.execute(
699                        "UPDATE skill_failure_patterns
700                         SET recovery_successes=recovery_successes+1,
701                             state=CASE
702                               WHEN recovery_successes+1 >= ?2 THEN 'resolved'
703                               WHEN cooldown_until <= ?3 THEN 'trial'
704                               ELSE 'cooldown' END,
705                             updated_at=?3 WHERE pattern_id=?1",
706                        params![pattern_id, policy.2, now.to_rfc3339()],
707                    )?;
708                }
709            }
710            let unresolved: i64 = tx.query_row(
711                "SELECT COUNT(*) FROM skill_failure_patterns
712                 WHERE skill_name=?1 AND skill_version=?2
713                   AND state IN ('active','cooldown','trial')",
714                params![skill_name, skill_version],
715                |row| row.get(0),
716            )?;
717            if unresolved == 0 {
718                let changed = tx.execute(
719                    "UPDATE skill_lifecycle SET state='trial',
720                     state_reason='failure patterns resolved; recovery trial started',
721                     updated_at=?3
722                     WHERE skill_name=?1 AND active_version=?2 AND state='degraded'",
723                    params![skill_name, skill_version, now.to_rfc3339()],
724                )?;
725                if changed > 0 {
726                    tx.execute(
727                        "INSERT INTO skill_lifecycle_events(
728                            skill_name, from_state, to_state, version, reason, created_at
729                         ) VALUES (?1,'degraded','trial',?2,
730                            'failure patterns resolved; recovery trial started',?3)",
731                        params![skill_name, skill_version, now.to_rfc3339()],
732                    )?;
733                }
734            }
735        }
736    }
737    Ok(())
738}
739
740pub(crate) fn map_shadow_evaluation(row: &rusqlite::Row<'_>) -> rusqlite::Result<ShadowEvaluation> {
741    Ok(ShadowEvaluation {
742        evaluation_id: row.get(0)?,
743        candidate_id: row.get(1)?,
744        sample_count: row.get(2)?,
745        baseline_passed: row.get(3)?,
746        candidate_passed: row.get(4)?,
747        baseline_utility_lower_bound: row.get(5)?,
748        candidate_utility_lower_bound: row.get(6)?,
749        baseline_cost_usd: row.get(7)?,
750        candidate_cost_usd: row.get(8)?,
751        baseline_duration_ms: row.get(9)?,
752        candidate_duration_ms: row.get(10)?,
753        regression_count: row.get(11)?,
754        verdict: row.get(12)?,
755        reason: row.get(13)?,
756        updated_at: row.get(14)?,
757    })
758}
759
760pub(crate) fn recompute_shadow_evaluation(
761    tx: &Transaction<'_>,
762    candidate_id: &str,
763) -> Result<ShadowEvaluation, MicroClawError> {
764    let (
765        samples,
766        baseline_passed,
767        candidate_passed,
768        baseline_cost,
769        candidate_cost,
770        baseline_duration,
771        candidate_duration,
772        regressions,
773    ): (i64, i64, i64, f64, f64, i64, i64, i64) = tx.query_row(
774        "SELECT COUNT(*),
775                COALESCE(SUM(CASE WHEN b.verdict='passed' THEN 1 ELSE 0 END),0),
776                COALESCE(SUM(CASE WHEN c.verdict='passed' THEN 1 ELSE 0 END),0),
777                COALESCE(SUM(b.cost_usd),0), COALESCE(SUM(c.cost_usd),0),
778                COALESCE(SUM(b.duration_ms),0), COALESCE(SUM(c.duration_ms),0),
779                COALESCE(SUM(CASE WHEN b.verdict='passed'
780                                      AND c.verdict='failed' THEN 1 ELSE 0 END),0)
781         FROM shadow_observations b
782         JOIN shadow_observations c
783           ON c.candidate_id=b.candidate_id AND c.pair_key=b.pair_key
784          AND c.arm='candidate'
785         WHERE b.candidate_id=?1 AND b.arm='baseline'",
786        params![candidate_id],
787        |row| {
788            Ok((
789                row.get(0)?,
790                row.get(1)?,
791                row.get(2)?,
792                row.get(3)?,
793                row.get(4)?,
794                row.get(5)?,
795                row.get(6)?,
796                row.get(7)?,
797            ))
798        },
799    )?;
800    let policy = tx.query_row(
801        "SELECT shadow_min_samples, shadow_promote_utility_margin,
802                shadow_max_cost_ratio, shadow_max_regressions,
803                utility_confidence_z
804         FROM skill_governance_policy WHERE singleton_id=1",
805        [],
806        |row| {
807            Ok((
808                row.get::<_, i64>(0)?,
809                row.get::<_, f64>(1)?,
810                row.get::<_, f64>(2)?,
811                row.get::<_, i64>(3)?,
812                row.get::<_, f64>(4)?,
813            ))
814        },
815    )?;
816    let baseline_utility = wilson_lower_bound(baseline_passed, samples, policy.4);
817    let candidate_utility = wilson_lower_bound(candidate_passed, samples, policy.4);
818    let cost_ratio = if baseline_cost <= f64::EPSILON {
819        if candidate_cost <= f64::EPSILON {
820            1.0
821        } else {
822            f64::INFINITY
823        }
824    } else {
825        candidate_cost / baseline_cost
826    };
827    let (verdict, reason) = if samples < policy.0 {
828        (
829            "pending",
830            format!("need {} paired samples; have {samples}", policy.0),
831        )
832    } else if regressions > policy.3 {
833        (
834            "failed",
835            format!("regressions {regressions} exceed {}", policy.3),
836        )
837    } else if candidate_utility + f64::EPSILON < baseline_utility + policy.1 {
838        (
839            "failed",
840            format!(
841                "candidate utility floor {candidate_utility:.3} does not exceed baseline {baseline_utility:.3} by {:.3}",
842                policy.1
843            ),
844        )
845    } else if cost_ratio > policy.2 {
846        (
847            "failed",
848            format!(
849                "candidate cost ratio {cost_ratio:.3} exceeds {:.3}",
850                policy.2
851            ),
852        )
853    } else {
854        (
855            "passed",
856            format!(
857                "paired shadow gate passed: utility {:.3}→{:.3}, cost ratio {:.3}, regressions {regressions}",
858                baseline_utility, candidate_utility, cost_ratio
859            ),
860        )
861    };
862    use sha2::{Digest, Sha256};
863    let evaluation_id = to_hex(&Sha256::digest(candidate_id.as_bytes()));
864    let previous: Option<String> = tx
865        .query_row(
866            "SELECT verdict FROM shadow_evaluations WHERE candidate_id=?1",
867            params![candidate_id],
868            |row| row.get(0),
869        )
870        .optional()?;
871    let now = chrono::Utc::now().to_rfc3339();
872    tx.execute(
873        "INSERT INTO shadow_evaluations(
874            evaluation_id, candidate_id, sample_count, baseline_passed,
875            candidate_passed, baseline_utility_lower_bound,
876            candidate_utility_lower_bound, baseline_cost_usd,
877            candidate_cost_usd, baseline_duration_ms, candidate_duration_ms,
878            regression_count, verdict, reason, updated_at
879         ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)
880         ON CONFLICT(candidate_id) DO UPDATE SET
881            sample_count=excluded.sample_count,
882            baseline_passed=excluded.baseline_passed,
883            candidate_passed=excluded.candidate_passed,
884            baseline_utility_lower_bound=excluded.baseline_utility_lower_bound,
885            candidate_utility_lower_bound=excluded.candidate_utility_lower_bound,
886            baseline_cost_usd=excluded.baseline_cost_usd,
887            candidate_cost_usd=excluded.candidate_cost_usd,
888            baseline_duration_ms=excluded.baseline_duration_ms,
889            candidate_duration_ms=excluded.candidate_duration_ms,
890            regression_count=excluded.regression_count,
891            verdict=excluded.verdict, reason=excluded.reason,
892            updated_at=excluded.updated_at",
893        params![
894            evaluation_id,
895            candidate_id,
896            samples,
897            baseline_passed,
898            candidate_passed,
899            baseline_utility,
900            candidate_utility,
901            baseline_cost,
902            candidate_cost,
903            baseline_duration,
904            candidate_duration,
905            regressions,
906            verdict,
907            reason,
908            now
909        ],
910    )?;
911    tx.execute(
912        "UPDATE skill_candidates SET status=?2, updated_at=?3
913         WHERE candidate_id=?1 AND status<>'promoted'",
914        params![
915            candidate_id,
916            match verdict {
917                "passed" => "shadow_passed",
918                "failed" => "shadow_failed",
919                _ => "shadow",
920            },
921            now
922        ],
923    )?;
924    if previous.as_deref() != Some(verdict) {
925        tx.execute(
926            "INSERT INTO learning_journal_events(
927                event_type, entity_type, entity_id, summary, evidence_json,
928                undo_action, created_at
929             ) VALUES ('shadow_evaluated','skill_candidate',?1,?2,?3,
930                       CASE WHEN ?4='passed' THEN 'archive_candidate'
931                            ELSE NULL END,?5)",
932            params![
933                candidate_id,
934                reason,
935                serde_json::json!({
936                    "sample_count": samples,
937                    "baseline_utility_lower_bound": baseline_utility,
938                    "candidate_utility_lower_bound": candidate_utility,
939                    "cost_ratio": cost_ratio,
940                    "regression_count": regressions
941                })
942                .to_string(),
943                verdict,
944                now
945            ],
946        )?;
947    }
948    tx.query_row(
949        "SELECT evaluation_id, candidate_id, sample_count,
950                baseline_passed, candidate_passed,
951                baseline_utility_lower_bound, candidate_utility_lower_bound,
952                baseline_cost_usd, candidate_cost_usd,
953                baseline_duration_ms, candidate_duration_ms,
954                regression_count, verdict, reason, updated_at
955         FROM shadow_evaluations WHERE candidate_id=?1",
956        params![candidate_id],
957        map_shadow_evaluation,
958    )
959    .map_err(Into::into)
960}
961
962pub(crate) fn refresh_expired_skill_outcomes(tx: &Transaction<'_>) -> Result<(), MicroClawError> {
963    let now = chrono::Utc::now().to_rfc3339();
964    let mut stmt = tx.prepare(
965        "SELECT DISTINCT run_id, skill_name, skill_version FROM skill_outcomes
966         WHERE valid_until IS NOT NULL AND valid_until <= ?1",
967    )?;
968    let expired = stmt
969        .query_map(params![now], |row| {
970            Ok((
971                row.get::<_, String>(0)?,
972                row.get::<_, String>(1)?,
973                row.get::<_, i64>(2)?,
974            ))
975        })?
976        .collect::<Result<Vec<_>, _>>()?;
977    drop(stmt);
978    for (run_id, _, _) in &expired {
979        sync_skill_outcomes_for_run(tx, run_id)?;
980        sync_skill_failure_patterns_for_run(tx, run_id)?;
981    }
982    let mut affected = expired
983        .into_iter()
984        .map(|(_, skill_name, version)| (skill_name, version))
985        .collect::<Vec<_>>();
986    affected.sort();
987    affected.dedup();
988    for (skill_name, version) in affected {
989        recompute_skill_lifecycle(tx, &skill_name, version, "verifier evidence expired")?;
990    }
991    Ok(())
992}
993
994pub(crate) fn evaluate_skill_lifecycle(
995    tx: &Transaction<'_>,
996    skill_name: &str,
997    version: i64,
998) -> Result<bool, MicroClawError> {
999    let current: Option<String> = tx
1000        .query_row(
1001            "SELECT state FROM skill_lifecycle
1002             WHERE skill_name=?1 AND active_version=?2",
1003            params![skill_name, version],
1004            |row| row.get(0),
1005        )
1006        .optional()?;
1007    let Some(current) = current else {
1008        return Ok(false);
1009    };
1010    if current == "archived" || current == "degraded" {
1011        return Ok(false);
1012    }
1013    let (total, passed, failed): (i64, i64, i64) = tx.query_row(
1014        "SELECT COUNT(*),
1015                COALESCE(SUM(CASE WHEN verdict='passed' THEN 1 ELSE 0 END), 0),
1016                COALESCE(SUM(CASE WHEN verdict='failed' THEN 1 ELSE 0 END), 0)
1017         FROM skill_outcomes
1018         WHERE skill_name=?1 AND skill_version=?2
1019           AND verifier_type IN ('deterministic','environmental','human','rule_based')
1020           AND attribution_confidence >= 0.999
1021           AND (valid_until IS NULL OR valid_until > ?3)",
1022        params![skill_name, version, chrono::Utc::now().to_rfc3339()],
1023        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1024    )?;
1025    let success_rate = if total == 0 {
1026        0.0
1027    } else {
1028        passed as f64 / total as f64
1029    };
1030    let policy = tx.query_row(
1031        "SELECT candidate_failures_to_degrade, trial_min_outcomes,
1032                trial_promote_rate, trial_degrade_rate, trusted_min_outcomes,
1033                trusted_degrade_rate, utility_confidence_z,
1034                trial_promote_utility_lower_bound,
1035                failure_pattern_min_failures, failure_pattern_cooldown_hours,
1036                failure_pattern_recovery_successes, shadow_min_samples,
1037                shadow_promote_utility_margin, shadow_max_cost_ratio,
1038                shadow_max_regressions
1039         FROM skill_governance_policy WHERE singleton_id=1",
1040        [],
1041        |row| {
1042            Ok(SkillGovernancePolicy {
1043                candidate_failures_to_degrade: row.get(0)?,
1044                trial_min_outcomes: row.get(1)?,
1045                trial_promote_rate: row.get(2)?,
1046                trial_degrade_rate: row.get(3)?,
1047                trusted_min_outcomes: row.get(4)?,
1048                trusted_degrade_rate: row.get(5)?,
1049                utility_confidence_z: row.get(6)?,
1050                trial_promote_utility_lower_bound: row.get(7)?,
1051                failure_pattern_min_failures: row.get(8)?,
1052                failure_pattern_cooldown_hours: row.get(9)?,
1053                failure_pattern_recovery_successes: row.get(10)?,
1054                shadow_min_samples: row.get(11)?,
1055                shadow_promote_utility_margin: row.get(12)?,
1056                shadow_max_cost_ratio: row.get(13)?,
1057                shadow_max_regressions: row.get(14)?,
1058            })
1059        },
1060    )?;
1061    let utility_lower_bound = wilson_lower_bound(passed, total, policy.utility_confidence_z);
1062    let transition = match current.as_str() {
1063        "candidate" if failed >= policy.candidate_failures_to_degrade => {
1064            Some(("degraded", "candidate exceeded verified failure threshold"))
1065        }
1066        "candidate" if passed >= 1 => Some(("trial", "first verified success")),
1067        "trial"
1068            if total >= policy.trial_min_outcomes
1069                && success_rate >= policy.trial_promote_rate
1070                && utility_lower_bound >= policy.trial_promote_utility_lower_bound =>
1071        {
1072            Some((
1073                "trusted",
1074                "trial passed raw-rate and risk-adjusted utility thresholds",
1075            ))
1076        }
1077        "trial"
1078            if total >= policy.trial_min_outcomes && success_rate < policy.trial_degrade_rate =>
1079        {
1080            Some(("degraded", "trial failed quality threshold"))
1081        }
1082        "trusted"
1083            if total >= policy.trusted_min_outcomes
1084                && success_rate < policy.trusted_degrade_rate =>
1085        {
1086            Some((
1087                "degraded",
1088                "trusted skill regressed below quality threshold",
1089            ))
1090        }
1091        _ => None,
1092    };
1093    let Some((next, reason)) = transition else {
1094        return Ok(false);
1095    };
1096    let now = chrono::Utc::now().to_rfc3339();
1097    if current == "trusted" && next == "degraded" {
1098        let rollback_version: Option<i64> = tx
1099            .query_row(
1100                "SELECT previous_trusted_version FROM skill_lifecycle
1101                 WHERE skill_name=?1 AND active_version=?2",
1102                params![skill_name, version],
1103                |row| row.get(0),
1104            )
1105            .optional()?
1106            .flatten();
1107        if let Some(rollback_version) = rollback_version {
1108            let exists: i64 = tx.query_row(
1109                "SELECT COUNT(*) FROM skill_versions
1110                 WHERE skill_name=?1 AND version=?2",
1111                params![skill_name, rollback_version],
1112                |row| row.get(0),
1113            )?;
1114            if exists > 0 {
1115                let rollback_reason =
1116                    format!("automatic rollback from v{version} to v{rollback_version}: {reason}");
1117                tx.execute(
1118                    "UPDATE skill_lifecycle SET state='trusted', active_version=?2,
1119                     previous_trusted_version=NULL, updated_at=?3, state_reason=?4
1120                     WHERE skill_name=?1",
1121                    params![skill_name, rollback_version, now, rollback_reason],
1122                )?;
1123                tx.execute(
1124                    "INSERT INTO skill_lifecycle_events(
1125                        skill_name, from_state, to_state, version, reason, created_at
1126                     ) VALUES (?1,'trusted','trusted',?2,?3,?4)",
1127                    params![skill_name, rollback_version, rollback_reason, now],
1128                )?;
1129                tx.execute(
1130                    "UPDATE skill_candidates SET status='rolled_back', updated_at=?3
1131                     WHERE skill_name=?1 AND candidate_version=?2
1132                       AND status='promoted'",
1133                    params![skill_name, version, now],
1134                )?;
1135                tx.execute(
1136                    "INSERT INTO learning_journal_events(
1137                        event_type, entity_type, entity_id, summary,
1138                        evidence_json, undo_action, created_at
1139                     ) VALUES ('automatic_rollback','skill',?1,?2,?3,NULL,?4)",
1140                    params![
1141                        skill_name,
1142                        rollback_reason,
1143                        serde_json::json!({
1144                            "failed_version": version,
1145                            "restored_version": rollback_version,
1146                            "trigger": reason
1147                        })
1148                        .to_string(),
1149                        now
1150                    ],
1151                )?;
1152                return Ok(true);
1153            }
1154        }
1155    }
1156    let previous_trusted = (current == "trusted").then_some(version);
1157    tx.execute(
1158        "UPDATE skill_lifecycle
1159         SET state=?2,
1160             previous_trusted_version=COALESCE(?3, previous_trusted_version),
1161             updated_at=?4, state_reason=?5
1162         WHERE skill_name=?1",
1163        params![skill_name, next, previous_trusted, now, reason],
1164    )?;
1165    tx.execute(
1166        "INSERT INTO skill_lifecycle_events(
1167            skill_name, from_state, to_state, version, reason, created_at
1168         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1169        params![skill_name, current, next, version, reason, now],
1170    )?;
1171    Ok(true)
1172}
1173
1174pub(crate) fn recompute_skill_lifecycle(
1175    tx: &Transaction<'_>,
1176    skill_name: &str,
1177    version: i64,
1178    reason: &str,
1179) -> Result<(), MicroClawError> {
1180    let current: Option<(String, String)> = tx
1181        .query_row(
1182            "SELECT state, source FROM skill_lifecycle
1183             WHERE skill_name=?1 AND active_version=?2",
1184            params![skill_name, version],
1185            |row| Ok((row.get(0)?, row.get(1)?)),
1186        )
1187        .optional()?;
1188    let Some((from_state, source)) = current else {
1189        return Ok(());
1190    };
1191    if from_state == "archived" {
1192        return Ok(());
1193    }
1194    let baseline = if source == "agent-created" {
1195        "candidate"
1196    } else {
1197        "trusted"
1198    };
1199    let now = chrono::Utc::now().to_rfc3339();
1200    tx.execute(
1201        "UPDATE skill_lifecycle
1202         SET state=?2, updated_at=?3, state_reason=?4
1203         WHERE skill_name=?1",
1204        params![skill_name, baseline, now, reason],
1205    )?;
1206    if from_state != baseline {
1207        tx.execute(
1208            "INSERT INTO skill_lifecycle_events(
1209                skill_name, from_state, to_state, version, reason, created_at
1210             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1211            params![skill_name, from_state, baseline, version, reason, now],
1212        )?;
1213    }
1214    // At most candidate -> trial -> trusted/degraded. Bound the loop so a
1215    // malformed future policy cannot create a transition cycle.
1216    for _ in 0..3 {
1217        if !evaluate_skill_lifecycle(tx, skill_name, version)? {
1218            break;
1219        }
1220    }
1221    Ok(())
1222}
1223
1224impl Database {
1225    pub fn register_skill_version(
1226        &self,
1227        skill_name: &str,
1228        version: i64,
1229        content: &str,
1230        source: &str,
1231    ) -> Result<(), MicroClawError> {
1232        if skill_name.trim().is_empty() || version <= 0 {
1233            return Err(MicroClawError::ToolExecution(
1234                "skill name must be non-empty and version must be positive".into(),
1235            ));
1236        }
1237        use sha2::{Digest, Sha256};
1238        let mut hasher = Sha256::new();
1239        hasher.update(content.as_bytes());
1240        let hash = to_hex(&hasher.finalize());
1241        let mut conn = self.lock_conn();
1242        let tx = conn.transaction()?;
1243        let now = chrono::Utc::now().to_rfc3339();
1244        let existing_hash: Option<String> = tx
1245            .query_row(
1246                "SELECT content_hash FROM skill_versions
1247                 WHERE skill_name=?1 AND version=?2",
1248                params![skill_name, version],
1249                |row| row.get(0),
1250            )
1251            .optional()?;
1252        if existing_hash
1253            .as_deref()
1254            .is_some_and(|existing| existing != hash)
1255        {
1256            return Err(MicroClawError::ToolExecution(format!(
1257                "skill version {skill_name}@{version} is immutable and already has different content"
1258            )));
1259        }
1260        tx.execute(
1261            "INSERT INTO skill_versions(
1262                skill_name, version, content, content_hash, source, created_at
1263             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1264             ON CONFLICT(skill_name, version) DO NOTHING",
1265            params![skill_name, version, content, hash, source, now],
1266        )?;
1267        let existing: Option<(String, i64)> = tx
1268            .query_row(
1269                "SELECT state, active_version FROM skill_lifecycle WHERE skill_name=?1",
1270                params![skill_name],
1271                |row| Ok((row.get(0)?, row.get(1)?)),
1272            )
1273            .optional()?;
1274        match existing {
1275            None => {
1276                let initial = if source == "agent-created" {
1277                    "candidate"
1278                } else {
1279                    "trusted"
1280                };
1281                tx.execute(
1282                    "INSERT INTO skill_lifecycle(
1283                        skill_name, state, active_version, source,
1284                        created_at, updated_at, state_reason
1285                     ) VALUES (?1, ?2, ?3, ?4, ?5, ?5, 'version registered')",
1286                    params![skill_name, initial, version, source, now],
1287                )?;
1288                tx.execute(
1289                    "INSERT INTO skill_lifecycle_events(
1290                        skill_name, from_state, to_state, version, reason, created_at
1291                     ) VALUES (?1, NULL, ?2, ?3, 'version registered', ?4)",
1292                    params![skill_name, initial, version, now],
1293                )?;
1294            }
1295            Some((state, active_version)) => {
1296                // Re-discovery of an identical current/older version must not
1297                // implicitly change the active version or lifecycle. Version
1298                // rollback is an explicit, audited operation.
1299                if version <= active_version {
1300                    tx.commit()?;
1301                    return Ok(());
1302                }
1303                let next_state = if source == "agent-created" && version > active_version {
1304                    "candidate"
1305                } else {
1306                    state.as_str()
1307                };
1308                let previous_trusted =
1309                    (state == "trusted" && version > active_version).then_some(active_version);
1310                tx.execute(
1311                    "UPDATE skill_lifecycle
1312                     SET state=?2, active_version=?3,
1313                         previous_trusted_version=COALESCE(?4, previous_trusted_version),
1314                         source=?5, updated_at=?6, state_reason='new version registered'
1315                     WHERE skill_name=?1",
1316                    params![
1317                        skill_name,
1318                        next_state,
1319                        version,
1320                        previous_trusted,
1321                        source,
1322                        now
1323                    ],
1324                )?;
1325                if next_state != state {
1326                    tx.execute(
1327                        "INSERT INTO skill_lifecycle_events(
1328                            skill_name, from_state, to_state, version, reason, created_at
1329                         ) VALUES (?1, ?2, ?3, ?4, 'new version registered', ?5)",
1330                        params![skill_name, state, next_state, version, now],
1331                    )?;
1332                }
1333            }
1334        }
1335        tx.commit()?;
1336        Ok(())
1337    }
1338
1339    pub fn get_skill_lifecycle(
1340        &self,
1341        skill_name: &str,
1342    ) -> Result<Option<SkillLifecycleRecord>, MicroClawError> {
1343        let conn = self.lock_conn();
1344        conn.query_row(
1345            "SELECT skill_name, state, active_version, previous_trusted_version,
1346                    source, created_at, updated_at, state_reason
1347             FROM skill_lifecycle WHERE skill_name=?1",
1348            params![skill_name],
1349            |row| {
1350                Ok(SkillLifecycleRecord {
1351                    skill_name: row.get(0)?,
1352                    state: row.get(1)?,
1353                    active_version: row.get(2)?,
1354                    previous_trusted_version: row.get(3)?,
1355                    source: row.get(4)?,
1356                    created_at: row.get(5)?,
1357                    updated_at: row.get(6)?,
1358                    state_reason: row.get(7)?,
1359                })
1360            },
1361        )
1362        .optional()
1363        .map_err(Into::into)
1364    }
1365
1366    pub fn get_skill_version_content(
1367        &self,
1368        skill_name: &str,
1369        version: i64,
1370    ) -> Result<Option<String>, MicroClawError> {
1371        let conn = self.lock_conn();
1372        conn.query_row(
1373            "SELECT content FROM skill_versions WHERE skill_name=?1 AND version=?2",
1374            params![skill_name, version],
1375            |row| row.get(0),
1376        )
1377        .optional()
1378        .map_err(Into::into)
1379    }
1380
1381    /// Evaluate a skill against verified outcomes from the current run's
1382    /// environment. Two or more matching outcomes with a pass rate below 50%
1383    /// form a learned contraindication until a new version is registered.
1384    pub fn evaluate_skill_applicability(
1385        &self,
1386        skill_name: &str,
1387        chat_id: i64,
1388    ) -> Result<SkillApplicability, MicroClawError> {
1389        let mut conn = self.lock_conn();
1390        let tx = conn.transaction()?;
1391        refresh_expired_skill_outcomes(&tx)?;
1392        let utility_confidence_z: f64 = tx.query_row(
1393            "SELECT utility_confidence_z FROM skill_governance_policy WHERE singleton_id=1",
1394            [],
1395            |row| row.get(0),
1396        )?;
1397        let context: Option<(Option<String>, String, String)> = tx
1398            .query_row(
1399                "SELECT environment_fingerprint, task_type, task_family
1400                 FROM experience_runs
1401                 WHERE chat_id=?1 AND status='running'
1402                 ORDER BY started_at DESC LIMIT 1",
1403                params![chat_id],
1404                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1405            )
1406            .optional()?;
1407        let Some((environment, task_type, task_family)) = context else {
1408            tx.commit()?;
1409            return Ok(SkillApplicability {
1410                environment_fingerprint: None,
1411                task_type: None,
1412                task_family: None,
1413                matching_outcomes: 0,
1414                passed_outcomes: 0,
1415                failed_outcomes: 0,
1416                success_rate: None,
1417                utility_lower_bound: None,
1418                allowed: true,
1419                reason: "no comparable verified task history".into(),
1420            });
1421        };
1422        let (total, passed, failed): (i64, i64, i64) = tx.query_row(
1423            "SELECT COUNT(o.id),
1424                    COALESCE(SUM(CASE WHEN o.verdict='passed' THEN 1 ELSE 0 END), 0),
1425                    COALESCE(SUM(CASE WHEN o.verdict='failed' THEN 1 ELSE 0 END), 0)
1426             FROM skill_outcomes o
1427             JOIN skill_lifecycle l
1428               ON l.skill_name=o.skill_name AND l.active_version=o.skill_version
1429             JOIN experience_runs r ON r.run_id=o.run_id
1430             WHERE o.skill_name=?1
1431               AND (?2 IS NULL OR r.environment_fingerprint=?2)
1432               AND r.task_family=?3
1433               AND o.verifier_type IN ('deterministic','environmental','human','rule_based')
1434               AND o.attribution_confidence >= 0.999
1435               AND (o.valid_until IS NULL OR o.valid_until > ?4)",
1436            params![
1437                skill_name,
1438                environment,
1439                task_family,
1440                chrono::Utc::now().to_rfc3339()
1441            ],
1442            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1443        )?;
1444        let success_rate = (total > 0).then_some(passed as f64 / total as f64);
1445        let utility_lower_bound =
1446            (total > 0).then_some(wilson_lower_bound(passed, total, utility_confidence_z));
1447        let active_pattern: Option<(String, Option<String>, i64, Option<String>)> = tx
1448            .query_row(
1449                "SELECT error_category, tool_name, failure_count, cooldown_until
1450                 FROM skill_failure_patterns p
1451                 JOIN skill_lifecycle l ON l.skill_name=p.skill_name
1452                    AND l.active_version=p.skill_version
1453                 WHERE p.skill_name=?1 AND p.task_family=?2
1454                   AND (p.environment_fingerprint IS NULL
1455                        OR p.environment_fingerprint=?3)
1456                   AND p.state IN ('active','cooldown')
1457                 ORDER BY p.failure_count DESC, p.updated_at DESC LIMIT 1",
1458                params![skill_name, task_family, environment],
1459                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1460            )
1461            .optional()?;
1462        let allowed = active_pattern.is_none() && (total < 2 || success_rate.unwrap_or(0.0) >= 0.5);
1463        let result = SkillApplicability {
1464            environment_fingerprint: environment,
1465            task_type: Some(task_type),
1466            task_family: Some(task_family),
1467            matching_outcomes: total,
1468            passed_outcomes: passed,
1469            failed_outcomes: failed,
1470            success_rate,
1471            utility_lower_bound,
1472            allowed,
1473            reason: if let Some((category, tool, count, cooldown)) = active_pattern {
1474                format!(
1475                    "learned contraindication: {count} {category} failure(s), tool={}, cooldown_until={}",
1476                    tool.as_deref().unwrap_or("any"),
1477                    cooldown.as_deref().unwrap_or("unknown")
1478                )
1479            } else if allowed {
1480                "no learned contraindication for this task family and environment".into()
1481            } else {
1482                "verified outcomes show repeated failure for this task family and environment"
1483                    .into()
1484            },
1485        };
1486        tx.commit()?;
1487        Ok(result)
1488    }
1489
1490    pub fn transition_skill_state(
1491        &self,
1492        skill_name: &str,
1493        to_state: &str,
1494        reason: &str,
1495    ) -> Result<bool, MicroClawError> {
1496        if !matches!(
1497            to_state,
1498            "candidate" | "trial" | "trusted" | "degraded" | "archived"
1499        ) {
1500            return Err(MicroClawError::ToolExecution(format!(
1501                "invalid skill lifecycle state: {to_state}"
1502            )));
1503        }
1504        let mut conn = self.lock_conn();
1505        let tx = conn.transaction()?;
1506        let current: Option<(String, i64)> = tx
1507            .query_row(
1508                "SELECT state, active_version FROM skill_lifecycle WHERE skill_name=?1",
1509                params![skill_name],
1510                |row| Ok((row.get(0)?, row.get(1)?)),
1511            )
1512            .optional()?;
1513        let Some((from_state, version)) = current else {
1514            return Ok(false);
1515        };
1516        let now = chrono::Utc::now().to_rfc3339();
1517        tx.execute(
1518            "UPDATE skill_lifecycle
1519             SET state=?2, updated_at=?3, state_reason=?4 WHERE skill_name=?1",
1520            params![skill_name, to_state, now, reason],
1521        )?;
1522        if from_state != to_state {
1523            tx.execute(
1524                "INSERT INTO skill_lifecycle_events(
1525                    skill_name, from_state, to_state, version, reason, created_at
1526                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1527                params![skill_name, from_state, to_state, version, reason, now],
1528            )?;
1529        }
1530        tx.commit()?;
1531        Ok(true)
1532    }
1533
1534    pub fn rollback_skill(
1535        &self,
1536        skill_name: &str,
1537        target_version: Option<i64>,
1538        reason: &str,
1539    ) -> Result<Option<(i64, String)>, MicroClawError> {
1540        let mut conn = self.lock_conn();
1541        let tx = conn.transaction()?;
1542        let current: Option<(String, i64, Option<i64>)> = tx
1543            .query_row(
1544                "SELECT state, active_version, previous_trusted_version
1545                 FROM skill_lifecycle WHERE skill_name=?1",
1546                params![skill_name],
1547                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1548            )
1549            .optional()?;
1550        let Some((from_state, active_version, previous_trusted)) = current else {
1551            return Ok(None);
1552        };
1553        let target = target_version.or(previous_trusted);
1554        let Some(target) = target else {
1555            return Ok(None);
1556        };
1557        let content: Option<String> = tx
1558            .query_row(
1559                "SELECT content FROM skill_versions WHERE skill_name=?1 AND version=?2",
1560                params![skill_name, target],
1561                |row| row.get(0),
1562            )
1563            .optional()?;
1564        let Some(content) = content else {
1565            return Ok(None);
1566        };
1567        let now = chrono::Utc::now().to_rfc3339();
1568        tx.execute(
1569            "UPDATE skill_lifecycle
1570             SET state='trusted', active_version=?2,
1571                 previous_trusted_version=?3, updated_at=?4, state_reason=?5
1572             WHERE skill_name=?1",
1573            params![skill_name, target, active_version, now, reason],
1574        )?;
1575        tx.execute(
1576            "INSERT INTO skill_lifecycle_events(
1577                skill_name, from_state, to_state, version, reason, created_at
1578             ) VALUES (?1, ?2, 'trusted', ?3, ?4, ?5)",
1579            params![skill_name, from_state, target, reason, now],
1580        )?;
1581        tx.execute(
1582            "UPDATE skill_candidates SET status='rolled_back', updated_at=?3
1583             WHERE skill_name=?1 AND candidate_version=?2 AND status='promoted'",
1584            params![skill_name, active_version, now],
1585        )?;
1586        tx.execute(
1587            "INSERT INTO learning_journal_events(
1588                event_type, entity_type, entity_id, summary, evidence_json,
1589                undo_action, created_at
1590             ) VALUES ('operator_rollback','skill',?1,?2,?3,NULL,?4)",
1591            params![
1592                skill_name,
1593                format!("rolled back {skill_name} from v{active_version} to v{target}: {reason}"),
1594                serde_json::json!({
1595                    "from_version": active_version,
1596                    "to_version": target,
1597                    "reason": reason
1598                })
1599                .to_string(),
1600                now
1601            ],
1602        )?;
1603        tx.commit()?;
1604        Ok(Some((target, content)))
1605    }
1606
1607    pub fn get_skill_learning_summaries(
1608        &self,
1609    ) -> Result<Vec<SkillLearningSummary>, MicroClawError> {
1610        let mut conn = self.lock_conn();
1611        let tx = conn.transaction()?;
1612        refresh_expired_skill_outcomes(&tx)?;
1613        let utility_confidence_z: f64 = tx.query_row(
1614            "SELECT utility_confidence_z FROM skill_governance_policy WHERE singleton_id=1",
1615            [],
1616            |row| row.get(0),
1617        )?;
1618        let mut stmt = tx.prepare(
1619            "SELECT l.skill_name, l.state, l.active_version,
1620                    COUNT(o.id),
1621                    COALESCE(SUM(CASE WHEN o.verdict='passed' THEN 1 ELSE 0 END), 0),
1622                    COALESCE(SUM(CASE WHEN o.verdict='failed' THEN 1 ELSE 0 END), 0),
1623                    MAX(o.updated_at)
1624             FROM skill_lifecycle l
1625             LEFT JOIN skill_outcomes o
1626              ON o.skill_name=l.skill_name AND o.skill_version=l.active_version
1627              AND o.verifier_type IN ('deterministic','environmental','human','rule_based')
1628              AND o.attribution_confidence >= 0.999
1629              AND (o.valid_until IS NULL OR o.valid_until >
1630                   strftime('%Y-%m-%dT%H:%M:%f+00:00','now'))
1631             GROUP BY l.skill_name, l.state, l.active_version
1632             ORDER BY l.updated_at DESC",
1633        )?;
1634        let rows = stmt.query_map([], |row| {
1635            let total: i64 = row.get(3)?;
1636            let passed: i64 = row.get(4)?;
1637            let failed: i64 = row.get(5)?;
1638            Ok(SkillLearningSummary {
1639                skill_name: row.get(0)?,
1640                state: row.get(1)?,
1641                active_version: row.get(2)?,
1642                total_outcomes: total,
1643                passed_outcomes: passed,
1644                failed_outcomes: failed,
1645                success_rate: if total == 0 {
1646                    0.0
1647                } else {
1648                    passed as f64 / total as f64
1649                },
1650                utility_lower_bound: wilson_lower_bound(passed, total, utility_confidence_z),
1651                last_outcome_at: row.get(6)?,
1652            })
1653        })?;
1654        let summaries = rows.collect::<Result<Vec<_>, _>>()?;
1655        drop(stmt);
1656        tx.commit()?;
1657        Ok(summaries)
1658    }
1659
1660    pub fn get_skill_task_utility_summaries(
1661        &self,
1662        skill_name: Option<&str>,
1663    ) -> Result<Vec<SkillTaskUtilitySummary>, MicroClawError> {
1664        let mut conn = self.lock_conn();
1665        let tx = conn.transaction()?;
1666        refresh_expired_skill_outcomes(&tx)?;
1667        let z: f64 = tx.query_row(
1668            "SELECT utility_confidence_z FROM skill_governance_policy WHERE singleton_id=1",
1669            [],
1670            |row| row.get(0),
1671        )?;
1672        let mut stmt = tx.prepare(
1673            "SELECT o.skill_name, o.skill_version, r.task_type, r.task_family,
1674                    COUNT(o.id),
1675                    COALESCE(SUM(CASE WHEN o.verdict='passed' THEN 1 ELSE 0 END), 0),
1676                    COALESCE(SUM(CASE WHEN o.verdict='failed' THEN 1 ELSE 0 END), 0)
1677             FROM skill_outcomes o
1678             JOIN experience_runs r ON r.run_id=o.run_id
1679             JOIN skill_lifecycle l
1680               ON l.skill_name=o.skill_name AND l.active_version=o.skill_version
1681             WHERE (?1 IS NULL OR o.skill_name=?1)
1682               AND o.verifier_type IN ('deterministic','environmental','human','rule_based')
1683               AND o.attribution_confidence >= 0.999
1684               AND (o.valid_until IS NULL OR o.valid_until >
1685                    strftime('%Y-%m-%dT%H:%M:%f+00:00','now'))
1686             GROUP BY o.skill_name, o.skill_version, r.task_type, r.task_family
1687             ORDER BY o.skill_name, COUNT(o.id) DESC, r.task_family",
1688        )?;
1689        let rows = stmt
1690            .query_map(params![skill_name], |row| {
1691                let total: i64 = row.get(4)?;
1692                let passed: i64 = row.get(5)?;
1693                Ok(SkillTaskUtilitySummary {
1694                    skill_name: row.get(0)?,
1695                    skill_version: row.get(1)?,
1696                    task_type: row.get(2)?,
1697                    task_family: row.get(3)?,
1698                    total_outcomes: total,
1699                    passed_outcomes: passed,
1700                    failed_outcomes: row.get(6)?,
1701                    success_rate: if total == 0 {
1702                        0.0
1703                    } else {
1704                        passed as f64 / total as f64
1705                    },
1706                    utility_lower_bound: wilson_lower_bound(passed, total, z),
1707                })
1708            })?
1709            .collect::<Result<Vec<_>, _>>()?;
1710        drop(stmt);
1711        tx.commit()?;
1712        Ok(rows)
1713    }
1714
1715    pub fn get_skill_failure_patterns(
1716        &self,
1717        skill_name: Option<&str>,
1718    ) -> Result<Vec<SkillFailurePattern>, MicroClawError> {
1719        let conn = self.lock_conn();
1720        let sql = "SELECT pattern_id, skill_name, skill_version, task_type,
1721                          task_family, environment_fingerprint, tool_name,
1722                          error_category, failure_count, recovery_successes,
1723                          state, cooldown_until, last_evidence, updated_at
1724                   FROM skill_failure_patterns
1725                   WHERE (?1 IS NULL OR skill_name=?1)
1726                   ORDER BY CASE state WHEN 'active' THEN 0 WHEN 'cooldown' THEN 1
1727                                      WHEN 'trial' THEN 2 ELSE 3 END,
1728                            failure_count DESC, updated_at DESC";
1729        let mut stmt = conn.prepare(sql)?;
1730        let rows = stmt
1731            .query_map(params![skill_name], |row| {
1732                Ok(SkillFailurePattern {
1733                    pattern_id: row.get(0)?,
1734                    skill_name: row.get(1)?,
1735                    skill_version: row.get(2)?,
1736                    task_type: row.get(3)?,
1737                    task_family: row.get(4)?,
1738                    environment_fingerprint: row.get(5)?,
1739                    tool_name: row.get(6)?,
1740                    error_category: row.get(7)?,
1741                    failure_count: row.get(8)?,
1742                    recovery_successes: row.get(9)?,
1743                    state: row.get(10)?,
1744                    cooldown_until: row.get(11)?,
1745                    last_evidence: row.get(12)?,
1746                    updated_at: row.get(13)?,
1747                })
1748            })?
1749            .collect::<Result<Vec<_>, _>>()?;
1750        Ok(rows)
1751    }
1752
1753    pub fn begin_skill_recovery_trial(&self, skill_name: &str) -> Result<bool, MicroClawError> {
1754        if skill_name.trim().is_empty() || skill_name.len() > 256 {
1755            return Err(MicroClawError::ToolExecution(
1756                "invalid skill name for recovery trial".into(),
1757            ));
1758        }
1759        let mut conn = self.lock_conn();
1760        let tx = conn.transaction()?;
1761        let now = chrono::Utc::now().to_rfc3339();
1762        let changed = tx.execute(
1763            "UPDATE skill_failure_patterns SET state='trial', updated_at=?2
1764             WHERE skill_name=?1 AND state IN ('active','cooldown')
1765               AND cooldown_until IS NOT NULL AND cooldown_until<=?2",
1766            params![skill_name, now],
1767        )?;
1768        if changed > 0 {
1769            let lifecycle: Option<(String, i64)> = tx
1770                .query_row(
1771                    "SELECT state, active_version FROM skill_lifecycle WHERE skill_name=?1",
1772                    params![skill_name],
1773                    |row| Ok((row.get(0)?, row.get(1)?)),
1774                )
1775                .optional()?;
1776            if let Some((state, version)) = lifecycle {
1777                if state == "degraded" {
1778                    tx.execute(
1779                        "UPDATE skill_lifecycle SET state='trial',
1780                         state_reason='cooldown elapsed; recovery trial explicitly started',
1781                         updated_at=?2 WHERE skill_name=?1",
1782                        params![skill_name, now],
1783                    )?;
1784                    tx.execute(
1785                        "INSERT INTO skill_lifecycle_events(
1786                            skill_name, from_state, to_state, version, reason, created_at
1787                         ) VALUES (?1,'degraded','trial',?2,
1788                           'cooldown elapsed; recovery trial explicitly started',?3)",
1789                        params![skill_name, version, now],
1790                    )?;
1791                }
1792            }
1793        }
1794        tx.commit()?;
1795        Ok(changed > 0)
1796    }
1797
1798    pub fn get_learning_claims(
1799        &self,
1800        chat_id: Option<i64>,
1801        limit: usize,
1802    ) -> Result<Vec<LearningClaim>, MicroClawError> {
1803        let conn = self.lock_conn();
1804        let mut stmt = conn.prepare(
1805            "SELECT c.claim_id, c.comparison_id, c.skill_name, c.base_version,
1806                    c.claim_version, c.statement, c.applicability_json, c.confidence,
1807                    c.evidence_json, c.counterexamples_json, c.status,
1808                    c.supersedes_claim_id, c.created_at, c.updated_at
1809             FROM learning_claims c
1810             JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
1811             WHERE (?1 IS NULL OR x.chat_id=?1)
1812             ORDER BY c.updated_at DESC LIMIT ?2",
1813        )?;
1814        let rows = stmt
1815            .query_map(params![chat_id, limit.clamp(1, 200) as i64], |row| {
1816                let applicability: String = row.get(6)?;
1817                let evidence: String = row.get(8)?;
1818                let counterexamples: String = row.get(9)?;
1819                Ok(LearningClaim {
1820                    claim_id: row.get(0)?,
1821                    comparison_id: row.get(1)?,
1822                    skill_name: row.get(2)?,
1823                    base_version: row.get(3)?,
1824                    claim_version: row.get(4)?,
1825                    statement: row.get(5)?,
1826                    applicability: serde_json::from_str(&applicability)
1827                        .unwrap_or(serde_json::Value::Null),
1828                    confidence: row.get(7)?,
1829                    evidence: serde_json::from_str(&evidence).unwrap_or(serde_json::Value::Null),
1830                    counterexamples: serde_json::from_str(&counterexamples)
1831                        .unwrap_or(serde_json::Value::Null),
1832                    status: row.get(10)?,
1833                    supersedes_claim_id: row.get(11)?,
1834                    created_at: row.get(12)?,
1835                    updated_at: row.get(13)?,
1836                })
1837            })?
1838            .collect::<Result<Vec<_>, _>>()?;
1839        Ok(rows)
1840    }
1841
1842    pub fn get_skill_candidates(
1843        &self,
1844        chat_id: Option<i64>,
1845        limit: usize,
1846    ) -> Result<Vec<SkillCandidate>, MicroClawError> {
1847        let conn = self.lock_conn();
1848        let mut stmt = conn.prepare(
1849            "SELECT k.candidate_id, k.claim_id, k.skill_name, k.base_version,
1850                    k.candidate_version, k.content, k.content_hash, k.status,
1851                    k.created_at, k.updated_at
1852             FROM skill_candidates k
1853             JOIN learning_claims c ON c.claim_id=k.claim_id
1854             JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
1855             WHERE (?1 IS NULL OR x.chat_id=?1)
1856             ORDER BY k.updated_at DESC LIMIT ?2",
1857        )?;
1858        let rows = stmt
1859            .query_map(params![chat_id, limit.clamp(1, 200) as i64], |row| {
1860                Ok(SkillCandidate {
1861                    candidate_id: row.get(0)?,
1862                    claim_id: row.get(1)?,
1863                    skill_name: row.get(2)?,
1864                    base_version: row.get(3)?,
1865                    candidate_version: row.get(4)?,
1866                    content: row.get(5)?,
1867                    content_hash: row.get(6)?,
1868                    status: row.get(7)?,
1869                    created_at: row.get(8)?,
1870                    updated_at: row.get(9)?,
1871                })
1872            })?
1873            .collect::<Result<Vec<_>, _>>()?;
1874        Ok(rows)
1875    }
1876
1877    pub fn create_skill_candidate_from_claim(
1878        &self,
1879        claim_id: &str,
1880        chat_id: i64,
1881    ) -> Result<SkillCandidate, MicroClawError> {
1882        let mut conn = self.lock_conn();
1883        let tx = conn.transaction()?;
1884        let claim: Option<(String, i64, String, String)> = tx
1885            .query_row(
1886                "SELECT c.skill_name, c.base_version, c.statement, c.status
1887                 FROM learning_claims c
1888                 JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
1889                 WHERE c.claim_id=?1 AND x.chat_id=?2",
1890                params![claim_id, chat_id],
1891                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1892            )
1893            .optional()?;
1894        let Some((skill_name, base_version, statement, claim_status)) = claim else {
1895            return Err(MicroClawError::ToolExecution(
1896                "learning claim not found".into(),
1897            ));
1898        };
1899        if claim_status == "archived" {
1900            return Err(MicroClawError::ToolExecution(
1901                "archived claim cannot create a candidate".into(),
1902            ));
1903        }
1904        if let Some(existing) = tx
1905            .query_row(
1906                "SELECT candidate_id FROM skill_candidates WHERE claim_id=?1",
1907                params![claim_id],
1908                |row| row.get::<_, String>(0),
1909            )
1910            .optional()?
1911        {
1912            drop(tx);
1913            drop(conn);
1914            return self
1915                .get_skill_candidates(Some(chat_id), 200)?
1916                .into_iter()
1917                .find(|candidate| candidate.candidate_id == existing)
1918                .ok_or_else(|| {
1919                    MicroClawError::ToolExecution("candidate projection missing".into())
1920                });
1921        }
1922        let base_content: String = tx.query_row(
1923            "SELECT content FROM skill_versions
1924             WHERE skill_name=?1 AND version=?2",
1925            params![skill_name, base_version],
1926            |row| row.get(0),
1927        )?;
1928        let candidate_version: i64 = tx.query_row(
1929            "SELECT MAX(version)+1 FROM (
1930               SELECT version FROM skill_versions WHERE skill_name=?1
1931               UNION ALL
1932               SELECT candidate_version AS version FROM skill_candidates
1933               WHERE skill_name=?1
1934             )",
1935            params![skill_name],
1936            |row| row.get::<_, Option<i64>>(0).map(|value| value.unwrap_or(1)),
1937        )?;
1938        let content = format!(
1939            "{base_content}\n\n## Learned guardrail (candidate v{candidate_version})\n\n- {statement}\n"
1940        );
1941        use sha2::{Digest, Sha256};
1942        let content_hash = to_hex(&Sha256::digest(content.as_bytes()));
1943        let candidate_id = uuid::Uuid::new_v4().to_string();
1944        let now = chrono::Utc::now().to_rfc3339();
1945        tx.execute(
1946            "INSERT INTO skill_candidates(
1947                candidate_id, claim_id, skill_name, base_version,
1948                candidate_version, content, content_hash, status,
1949                created_at, updated_at
1950             ) VALUES (?1,?2,?3,?4,?5,?6,?7,'candidate',?8,?8)",
1951            params![
1952                candidate_id,
1953                claim_id,
1954                skill_name,
1955                base_version,
1956                candidate_version,
1957                content,
1958                content_hash,
1959                now
1960            ],
1961        )?;
1962        tx.execute(
1963            "UPDATE learning_claims SET status='trial', updated_at=?2
1964             WHERE claim_id=?1",
1965            params![claim_id, now],
1966        )?;
1967        tx.execute(
1968            "INSERT INTO learning_journal_events(
1969                event_type, entity_type, entity_id, summary, evidence_json,
1970                undo_action, created_at
1971             ) VALUES ('candidate_created','skill_candidate',?1,?2,?3,
1972                       'archive_candidate',?4)",
1973            params![
1974                candidate_id,
1975                format!("{skill_name} candidate v{candidate_version} created from claim"),
1976                serde_json::json!({
1977                    "claim_id": claim_id,
1978                    "base_version": base_version,
1979                    "content_hash": content_hash
1980                })
1981                .to_string(),
1982                now
1983            ],
1984        )?;
1985        tx.commit()?;
1986        drop(conn);
1987        self.get_skill_candidates(Some(chat_id), 200)?
1988            .into_iter()
1989            .find(|candidate| candidate.candidate_id == candidate_id)
1990            .ok_or_else(|| MicroClawError::ToolExecution("candidate projection missing".into()))
1991    }
1992
1993    #[expect(clippy::too_many_arguments)]
1994    pub fn record_shadow_observation(
1995        &self,
1996        candidate_id: &str,
1997        chat_id: i64,
1998        pair_key: &str,
1999        arm: &str,
2000        run_id: &str,
2001        verdict: &str,
2002        cost_usd: f64,
2003        duration_ms: i64,
2004        evidence: Option<&str>,
2005    ) -> Result<ShadowEvaluation, MicroClawError> {
2006        if !matches!(arm, "baseline" | "candidate")
2007            || !matches!(verdict, "passed" | "failed")
2008            || pair_key.trim().is_empty()
2009            || pair_key.len() > 128
2010            || !cost_usd.is_finite()
2011            || cost_usd < 0.0
2012            || duration_ms < 0
2013        {
2014            return Err(MicroClawError::ToolExecution(
2015                "invalid shadow observation".into(),
2016            ));
2017        }
2018        let mut conn = self.lock_conn();
2019        let tx = conn.transaction()?;
2020        let exists: i64 = tx.query_row(
2021            "SELECT COUNT(*) FROM skill_candidates c
2022             JOIN learning_claims l ON l.claim_id=c.claim_id
2023             JOIN experience_comparisons x ON x.comparison_id=l.comparison_id
2024             JOIN experience_runs r ON r.run_id=?2
2025             JOIN verifier_results v ON v.run_id=r.run_id
2026             WHERE c.candidate_id=?1
2027               AND c.status NOT IN ('archived','promoted')
2028               AND x.chat_id=?5
2029               AND r.task_family=json_extract(l.applicability_json,'$.task_family')
2030               AND (
2031                 json_extract(l.applicability_json,'$.environment_fingerprint') IS NULL
2032                 OR r.environment_fingerprint=
2033                    json_extract(l.applicability_json,'$.environment_fingerprint')
2034               )
2035               AND v.verdict=?3
2036               AND v.verifier_type IN (
2037                 'deterministic','environmental','human','rule_based'
2038               )
2039               AND (v.valid_until IS NULL OR v.valid_until>?4)",
2040            params![
2041                candidate_id,
2042                run_id,
2043                verdict,
2044                chrono::Utc::now().to_rfc3339(),
2045                chat_id
2046            ],
2047            |row| row.get(0),
2048        )?;
2049        if exists == 0 {
2050            return Err(MicroClawError::ToolExecution(
2051                "candidate or strongly verified comparable run not eligible for shadow evaluation"
2052                    .into(),
2053            ));
2054        }
2055        let now = chrono::Utc::now().to_rfc3339();
2056        tx.execute(
2057            "INSERT INTO shadow_observations(
2058                candidate_id, pair_key, arm, run_id, verdict, cost_usd,
2059                duration_ms, evidence, created_at
2060             ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)
2061             ON CONFLICT(candidate_id,pair_key,arm) DO UPDATE SET
2062                run_id=excluded.run_id, verdict=excluded.verdict,
2063                cost_usd=excluded.cost_usd, duration_ms=excluded.duration_ms,
2064                evidence=excluded.evidence, created_at=excluded.created_at",
2065            params![
2066                candidate_id,
2067                pair_key,
2068                arm,
2069                run_id,
2070                verdict,
2071                cost_usd,
2072                duration_ms,
2073                evidence,
2074                now
2075            ],
2076        )?;
2077        let evaluation = recompute_shadow_evaluation(&tx, candidate_id)?;
2078        tx.commit()?;
2079        Ok(evaluation)
2080    }
2081
2082    pub fn get_shadow_evaluations(
2083        &self,
2084        chat_id: Option<i64>,
2085    ) -> Result<Vec<ShadowEvaluation>, MicroClawError> {
2086        let conn = self.lock_conn();
2087        let mut stmt = conn.prepare(
2088            "SELECT e.evaluation_id, e.candidate_id, e.sample_count,
2089                    e.baseline_passed, e.candidate_passed,
2090                    e.baseline_utility_lower_bound, e.candidate_utility_lower_bound,
2091                    e.baseline_cost_usd, e.candidate_cost_usd,
2092                    e.baseline_duration_ms, e.candidate_duration_ms,
2093                    e.regression_count, e.verdict, e.reason, e.updated_at
2094             FROM shadow_evaluations e
2095             JOIN skill_candidates k ON k.candidate_id=e.candidate_id
2096             JOIN learning_claims c ON c.claim_id=k.claim_id
2097             JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
2098             WHERE (?1 IS NULL OR x.chat_id=?1)
2099             ORDER BY e.updated_at DESC",
2100        )?;
2101        let rows = stmt
2102            .query_map(params![chat_id], map_shadow_evaluation)?
2103            .collect::<Result<Vec<_>, _>>()?;
2104        Ok(rows)
2105    }
2106
2107    pub fn promote_shadow_candidate(
2108        &self,
2109        candidate_id: &str,
2110        chat_id: i64,
2111    ) -> Result<(String, i64), MicroClawError> {
2112        let mut conn = self.lock_conn();
2113        let tx = conn.transaction()?;
2114        let candidate: Option<(String, i64, i64, String, String, String)> = tx
2115            .query_row(
2116                "SELECT c.skill_name, c.base_version, c.candidate_version,
2117                        c.content, c.content_hash, e.verdict
2118                 FROM skill_candidates c
2119                 JOIN shadow_evaluations e ON e.candidate_id=c.candidate_id
2120                 JOIN learning_claims l ON l.claim_id=c.claim_id
2121                 JOIN experience_comparisons x ON x.comparison_id=l.comparison_id
2122                 WHERE c.candidate_id=?1 AND c.status='shadow_passed'
2123                   AND x.chat_id=?2",
2124                params![candidate_id, chat_id],
2125                |row| {
2126                    Ok((
2127                        row.get(0)?,
2128                        row.get(1)?,
2129                        row.get(2)?,
2130                        row.get(3)?,
2131                        row.get(4)?,
2132                        row.get(5)?,
2133                    ))
2134                },
2135            )
2136            .optional()?;
2137        let Some((skill_name, base_version, candidate_version, content, hash, verdict)) = candidate
2138        else {
2139            return Err(MicroClawError::ToolExecution(
2140                "candidate has not passed shadow evaluation".into(),
2141            ));
2142        };
2143        if verdict != "passed" {
2144            return Err(MicroClawError::ToolExecution(
2145                "shadow evaluation gate did not pass".into(),
2146            ));
2147        }
2148        let lifecycle: Option<(String, i64)> = tx
2149            .query_row(
2150                "SELECT state, active_version FROM skill_lifecycle WHERE skill_name=?1",
2151                params![skill_name],
2152                |row| Ok((row.get(0)?, row.get(1)?)),
2153            )
2154            .optional()?;
2155        if lifecycle.as_ref().map(|item| item.1) != Some(base_version) {
2156            return Err(MicroClawError::ToolExecution(
2157                "candidate base version is no longer active; regenerate and re-evaluate".into(),
2158            ));
2159        }
2160        let now = chrono::Utc::now().to_rfc3339();
2161        tx.execute(
2162            "INSERT INTO skill_versions(
2163                skill_name, version, content, content_hash, source, created_at
2164             ) VALUES (?1,?2,?3,?4,'comparative-reflection',?5)",
2165            params![skill_name, candidate_version, content, hash, now],
2166        )?;
2167        tx.execute(
2168            "UPDATE skill_lifecycle SET state='trusted', active_version=?2,
2169                 previous_trusted_version=?3, source='comparative-reflection',
2170                 state_reason='shadow evaluation passed', updated_at=?4
2171             WHERE skill_name=?1",
2172            params![skill_name, candidate_version, base_version, now],
2173        )?;
2174        tx.execute(
2175            "UPDATE skill_candidates SET status='promoted', updated_at=?2
2176             WHERE candidate_id=?1",
2177            params![candidate_id, now],
2178        )?;
2179        tx.execute(
2180            "UPDATE learning_claims SET status='trusted', updated_at=?2
2181             WHERE claim_id=(SELECT claim_id FROM skill_candidates
2182                              WHERE candidate_id=?1)",
2183            params![candidate_id, now],
2184        )?;
2185        tx.execute(
2186            "INSERT INTO skill_lifecycle_events(
2187                skill_name, from_state, to_state, version, reason, created_at
2188             ) VALUES (?1,?2,'trusted',?3,'shadow evaluation passed',?4)",
2189            params![
2190                skill_name,
2191                lifecycle
2192                    .map(|item| item.0)
2193                    .unwrap_or_else(|| "candidate".into()),
2194                candidate_version,
2195                now
2196            ],
2197        )?;
2198        tx.execute(
2199            "INSERT INTO learning_journal_events(
2200                event_type, entity_type, entity_id, summary, evidence_json,
2201                undo_action, created_at
2202             ) VALUES ('candidate_promoted','skill_candidate',?1,?2,?3,
2203                       'rollback_skill',?4)",
2204            params![
2205                candidate_id,
2206                format!("{skill_name} promoted from v{base_version} to v{candidate_version}"),
2207                serde_json::json!({
2208                    "skill_name": skill_name,
2209                    "from_version": base_version,
2210                    "to_version": candidate_version
2211                })
2212                .to_string(),
2213                now
2214            ],
2215        )?;
2216        tx.commit()?;
2217        Ok((skill_name, candidate_version))
2218    }
2219
2220    pub fn archive_learning_entity(
2221        &self,
2222        entity_type: &str,
2223        entity_id: &str,
2224        chat_id: i64,
2225    ) -> Result<bool, MicroClawError> {
2226        let conn = self.lock_conn();
2227        let now = chrono::Utc::now().to_rfc3339();
2228        let changed = match entity_type {
2229            "learning_claim" => conn.execute(
2230                "UPDATE learning_claims SET status='archived', updated_at=?2
2231                 WHERE claim_id=?1 AND status NOT IN ('trusted','archived')
2232                   AND comparison_id IN (
2233                     SELECT comparison_id FROM experience_comparisons
2234                     WHERE chat_id=?3
2235                   )",
2236                params![entity_id, now, chat_id],
2237            )?,
2238            "skill_candidate" => conn.execute(
2239                "UPDATE skill_candidates SET status='archived', updated_at=?2
2240                 WHERE candidate_id=?1 AND status NOT IN ('promoted','archived')
2241                   AND claim_id IN (
2242                     SELECT c.claim_id FROM learning_claims c
2243                     JOIN experience_comparisons x
2244                       ON x.comparison_id=c.comparison_id
2245                     WHERE x.chat_id=?3
2246                   )",
2247                params![entity_id, now, chat_id],
2248            )?,
2249            _ => {
2250                return Err(MicroClawError::ToolExecution(
2251                    "unsupported learning entity type".into(),
2252                ));
2253            }
2254        };
2255        if changed > 0 {
2256            conn.execute(
2257                "INSERT INTO learning_journal_events(
2258                    event_type, entity_type, entity_id, summary,
2259                    evidence_json, created_at
2260                 ) VALUES ('entity_archived',?1,?2,?3,?4,?5)",
2261                params![
2262                    entity_type,
2263                    entity_id,
2264                    format!("archived {entity_type} {entity_id}"),
2265                    serde_json::json!({"chat_id": chat_id}).to_string(),
2266                    now
2267                ],
2268            )?;
2269        }
2270        Ok(changed > 0)
2271    }
2272
2273    pub fn get_learning_journal(
2274        &self,
2275        chat_id: Option<i64>,
2276        limit: usize,
2277    ) -> Result<Vec<LearningJournalEvent>, MicroClawError> {
2278        let conn = self.lock_conn();
2279        let mut stmt = conn.prepare(
2280            "SELECT id, event_type, entity_type, entity_id, summary,
2281                    evidence_json, undo_action, created_at
2282             FROM learning_journal_events j
2283             WHERE ?1 IS NULL OR EXISTS(
2284               SELECT 1 FROM experience_comparisons x
2285               LEFT JOIN learning_claims c ON c.comparison_id=x.comparison_id
2286               LEFT JOIN skill_candidates k ON k.claim_id=c.claim_id
2287               WHERE x.chat_id=?1 AND (
2288                 (j.entity_type='learning_claim' AND j.entity_id=c.claim_id)
2289                 OR (j.entity_type='skill_candidate' AND j.entity_id=k.candidate_id)
2290                 OR (j.entity_type='skill' AND j.entity_id=x.skill_name)
2291               )
2292             )
2293             ORDER BY j.created_at DESC LIMIT ?2",
2294        )?;
2295        let rows = stmt
2296            .query_map(params![chat_id, limit.clamp(1, 500) as i64], |row| {
2297                let evidence: String = row.get(5)?;
2298                Ok(LearningJournalEvent {
2299                    id: row.get(0)?,
2300                    event_type: row.get(1)?,
2301                    entity_type: row.get(2)?,
2302                    entity_id: row.get(3)?,
2303                    summary: row.get(4)?,
2304                    evidence: serde_json::from_str(&evidence).unwrap_or(serde_json::Value::Null),
2305                    undo_action: row.get(6)?,
2306                    created_at: row.get(7)?,
2307                })
2308            })?
2309            .collect::<Result<Vec<_>, _>>()?;
2310        Ok(rows)
2311    }
2312
2313    pub fn get_skill_governance_policy(&self) -> Result<SkillGovernancePolicy, MicroClawError> {
2314        let conn = self.lock_conn();
2315        conn.query_row(
2316            "SELECT candidate_failures_to_degrade, trial_min_outcomes,
2317                trial_promote_rate, trial_degrade_rate, trusted_min_outcomes,
2318                    trusted_degrade_rate, utility_confidence_z,
2319                    trial_promote_utility_lower_bound,
2320                    failure_pattern_min_failures, failure_pattern_cooldown_hours,
2321                    failure_pattern_recovery_successes, shadow_min_samples,
2322                    shadow_promote_utility_margin, shadow_max_cost_ratio,
2323                    shadow_max_regressions
2324             FROM skill_governance_policy WHERE singleton_id=1",
2325            [],
2326            |row| {
2327                Ok(SkillGovernancePolicy {
2328                    candidate_failures_to_degrade: row.get(0)?,
2329                    trial_min_outcomes: row.get(1)?,
2330                    trial_promote_rate: row.get(2)?,
2331                    trial_degrade_rate: row.get(3)?,
2332                    trusted_min_outcomes: row.get(4)?,
2333                    trusted_degrade_rate: row.get(5)?,
2334                    utility_confidence_z: row.get(6)?,
2335                    trial_promote_utility_lower_bound: row.get(7)?,
2336                    failure_pattern_min_failures: row.get(8)?,
2337                    failure_pattern_cooldown_hours: row.get(9)?,
2338                    failure_pattern_recovery_successes: row.get(10)?,
2339                    shadow_min_samples: row.get(11)?,
2340                    shadow_promote_utility_margin: row.get(12)?,
2341                    shadow_max_cost_ratio: row.get(13)?,
2342                    shadow_max_regressions: row.get(14)?,
2343                })
2344            },
2345        )
2346        .map_err(Into::into)
2347    }
2348
2349    pub fn update_skill_governance_policy(
2350        &self,
2351        policy: &SkillGovernancePolicy,
2352    ) -> Result<(), MicroClawError> {
2353        validate_skill_governance_policy(policy)?;
2354        let mut conn = self.lock_conn();
2355        let tx = conn.transaction()?;
2356        tx.execute(
2357            "UPDATE skill_governance_policy
2358             SET candidate_failures_to_degrade=?1, trial_min_outcomes=?2,
2359                 trial_promote_rate=?3, trial_degrade_rate=?4,
2360                 trusted_min_outcomes=?5, trusted_degrade_rate=?6,
2361                 utility_confidence_z=?7,
2362                 trial_promote_utility_lower_bound=?8,
2363                 failure_pattern_min_failures=?9,
2364                 failure_pattern_cooldown_hours=?10,
2365                 failure_pattern_recovery_successes=?11,
2366                 shadow_min_samples=?12,
2367                 shadow_promote_utility_margin=?13,
2368                 shadow_max_cost_ratio=?14,
2369                 shadow_max_regressions=?15,
2370                 updated_at=?16
2371             WHERE singleton_id=1",
2372            params![
2373                policy.candidate_failures_to_degrade,
2374                policy.trial_min_outcomes,
2375                policy.trial_promote_rate,
2376                policy.trial_degrade_rate,
2377                policy.trusted_min_outcomes,
2378                policy.trusted_degrade_rate,
2379                policy.utility_confidence_z,
2380                policy.trial_promote_utility_lower_bound,
2381                policy.failure_pattern_min_failures,
2382                policy.failure_pattern_cooldown_hours,
2383                policy.failure_pattern_recovery_successes,
2384                policy.shadow_min_samples,
2385                policy.shadow_promote_utility_margin,
2386                policy.shadow_max_cost_ratio,
2387                policy.shadow_max_regressions,
2388                chrono::Utc::now().to_rfc3339()
2389            ],
2390        )?;
2391        let now = chrono::Utc::now();
2392        let cooldown_until =
2393            (now + chrono::Duration::hours(policy.failure_pattern_cooldown_hours)).to_rfc3339();
2394        tx.execute(
2395            "UPDATE skill_failure_patterns
2396             SET state=CASE
2397                   WHEN failure_count < ?1 THEN 'observed'
2398                   WHEN state='observed' THEN 'active'
2399                   ELSE state END,
2400                 cooldown_until=CASE
2401                   WHEN failure_count < ?1 THEN NULL
2402                   WHEN state='observed' THEN ?2
2403                   ELSE cooldown_until END,
2404                 updated_at=?3",
2405            params![
2406                policy.failure_pattern_min_failures,
2407                cooldown_until,
2408                now.to_rfc3339()
2409            ],
2410        )?;
2411        let mut stmt = tx.prepare(
2412            "SELECT skill_name, active_version FROM skill_lifecycle
2413             WHERE state IN ('candidate','trial','trusted')",
2414        )?;
2415        let active = stmt
2416            .query_map([], |row| {
2417                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2418            })?
2419            .collect::<Result<Vec<_>, _>>()?;
2420        drop(stmt);
2421        for (skill_name, version) in active {
2422            evaluate_skill_lifecycle(&tx, &skill_name, version)?;
2423        }
2424        tx.commit()?;
2425        Ok(())
2426    }
2427
2428    /// Append a row to the skill activation log. `chat_id` may be 0 for
2429    /// channel-less invocations (e.g. tests).
2430    pub fn log_skill_activation(
2431        &self,
2432        skill_name: &str,
2433        chat_id: i64,
2434    ) -> Result<(), MicroClawError> {
2435        let conn = self.lock_conn();
2436        let now = chrono::Utc::now().to_rfc3339();
2437        let experience_run_id: Option<String> = conn
2438            .query_row(
2439                "SELECT run_id FROM experience_runs
2440                 WHERE chat_id=?1 AND status='running'
2441                 ORDER BY started_at DESC LIMIT 1",
2442                params![chat_id],
2443                |row| row.get(0),
2444            )
2445            .optional()?;
2446        let skill_version: Option<i64> = conn
2447            .query_row(
2448                "SELECT active_version FROM skill_lifecycle WHERE skill_name=?1",
2449                params![skill_name],
2450                |row| row.get(0),
2451            )
2452            .optional()?;
2453        conn.execute(
2454            "INSERT INTO skill_activation_logs(
2455                skill_name, chat_id, activated_at, experience_run_id, skill_version
2456             ) VALUES (?1, ?2, ?3, ?4, ?5)",
2457            params![skill_name, chat_id, now, experience_run_id, skill_version],
2458        )?;
2459        Ok(())
2460    }
2461
2462    /// Most-recent activation timestamp for a skill, or `None` if never
2463    /// activated. Used by the auto-archive job.
2464    pub fn last_skill_activation_at(
2465        &self,
2466        skill_name: &str,
2467    ) -> Result<Option<String>, MicroClawError> {
2468        let conn = self.lock_conn();
2469        let result = conn.query_row(
2470            "SELECT activated_at FROM skill_activation_logs
2471             WHERE skill_name = ?1
2472             ORDER BY activated_at DESC
2473             LIMIT 1",
2474            params![skill_name],
2475            |row| row.get::<_, String>(0),
2476        );
2477        match result {
2478            Ok(ts) => Ok(Some(ts)),
2479            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2480            Err(e) => Err(e.into()),
2481        }
2482    }
2483
2484    /// Activation counts for every skill seen in the last `since` window.
2485    /// Returns `(skill_name, count)` rows ordered by count descending.
2486    /// Used by the insights surface and operator dashboards.
2487    pub fn skill_activation_counts_since(
2488        &self,
2489        since: &str,
2490    ) -> Result<Vec<(String, i64)>, MicroClawError> {
2491        let conn = self.lock_conn();
2492        let mut stmt = conn.prepare(
2493            "SELECT skill_name, COUNT(*) AS n
2494             FROM skill_activation_logs
2495             WHERE activated_at >= ?1
2496             GROUP BY skill_name
2497             ORDER BY n DESC, skill_name ASC",
2498        )?;
2499        let rows = stmt
2500            .query_map(params![since], |row| {
2501                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2502            })?
2503            .collect::<Result<Vec<_>, _>>()?;
2504        Ok(rows)
2505    }
2506}
2507
2508#[cfg(test)]
2509mod tests {
2510    use super::*;
2511    use crate::internal::storage::db::test_support::*;
2512
2513    #[test]
2514    fn test_skill_activation_log_and_query() {
2515        let (db, dir) = test_db();
2516        // No activations yet → None
2517        assert!(db.last_skill_activation_at("alpha").unwrap().is_none());
2518
2519        db.log_skill_activation("alpha", 7).unwrap();
2520        std::thread::sleep(std::time::Duration::from_millis(5));
2521        db.log_skill_activation("alpha", 7).unwrap();
2522        db.log_skill_activation("beta", 8).unwrap();
2523
2524        let last_alpha = db.last_skill_activation_at("alpha").unwrap().unwrap();
2525        let last_beta = db.last_skill_activation_at("beta").unwrap().unwrap();
2526        assert!(last_alpha >= last_beta || last_alpha <= last_beta);
2527
2528        let counts = db
2529            .skill_activation_counts_since("1970-01-01T00:00:00Z")
2530            .unwrap();
2531        let alpha = counts.iter().find(|(n, _)| n == "alpha").unwrap();
2532        let beta = counts.iter().find(|(n, _)| n == "beta").unwrap();
2533        assert_eq!(alpha.1, 2);
2534        assert_eq!(beta.1, 1);
2535        // Counts ordered by n DESC then name — alpha first.
2536        assert_eq!(counts[0].0, "alpha");
2537
2538        // Cutoff in the future → no rows.
2539        let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
2540        let none = db.skill_activation_counts_since(&future).unwrap();
2541        assert!(none.is_empty());
2542        cleanup(&dir);
2543    }
2544
2545    #[test]
2546    fn learning_substrate_degrades_and_rolls_back_skill_version() {
2547        let (db, dir) = test_db();
2548        db.register_skill_version("browser-flow", 1, "safe v1", "agent-created")
2549            .unwrap();
2550        // Promote v1 to trusted.
2551        for index in 1..=3 {
2552            let run_id = format!("good-{index}");
2553            db.start_experience_run(&run_id, None, 7, "web", "interactive", "browse", None)
2554                .unwrap();
2555            db.log_skill_activation("browser-flow", 7).unwrap();
2556            db.record_verifier_result(
2557                &run_id,
2558                "deterministic",
2559                "browser_assertion",
2560                "passed",
2561                1.0,
2562                None,
2563                None,
2564                None,
2565            )
2566            .unwrap();
2567        }
2568        assert_eq!(
2569            db.get_skill_lifecycle("browser-flow")
2570                .unwrap()
2571                .unwrap()
2572                .state,
2573            "trusted"
2574        );
2575
2576        db.register_skill_version("browser-flow", 2, "risky v2", "agent-created")
2577            .unwrap();
2578        db.register_skill_version("browser-flow", 1, "safe v1", "agent-created")
2579            .unwrap();
2580        assert_eq!(
2581            db.get_skill_lifecycle("browser-flow")
2582                .unwrap()
2583                .unwrap()
2584                .active_version,
2585            2,
2586            "re-registering an older version must not perform an implicit rollback"
2587        );
2588        assert!(
2589            db.register_skill_version("browser-flow", 1, "mutated v1", "agent-created")
2590                .is_err(),
2591            "recorded versions must be immutable"
2592        );
2593        assert_eq!(
2594            db.get_skill_lifecycle("browser-flow")
2595                .unwrap()
2596                .unwrap()
2597                .state,
2598            "candidate"
2599        );
2600        for index in 1..=2 {
2601            let run_id = format!("bad-{index}");
2602            db.start_experience_run(&run_id, None, 7, "web", "interactive", "browse", None)
2603                .unwrap();
2604            db.log_skill_activation("browser-flow", 7).unwrap();
2605            db.record_verifier_result(
2606                &run_id,
2607                "deterministic",
2608                "browser_assertion",
2609                "failed",
2610                1.0,
2611                Some("wrong page"),
2612                None,
2613                None,
2614            )
2615            .unwrap();
2616        }
2617        assert_eq!(
2618            db.get_skill_lifecycle("browser-flow")
2619                .unwrap()
2620                .unwrap()
2621                .state,
2622            "degraded"
2623        );
2624        let (version, content) = db
2625            .rollback_skill("browser-flow", None, "operator rollback")
2626            .unwrap()
2627            .unwrap();
2628        assert_eq!(version, 1);
2629        assert_eq!(content, "safe v1");
2630        assert_eq!(
2631            db.get_skill_lifecycle("browser-flow")
2632                .unwrap()
2633                .unwrap()
2634                .state,
2635            "trusted"
2636        );
2637        cleanup(&dir);
2638    }
2639
2640    #[test]
2641    fn comparative_reflection_shadow_promotion_and_automatic_rollback() {
2642        let (db, dir) = test_db();
2643        db.register_skill_version("reflective-deploy", 1, "deploy safely", "built-in")
2644            .unwrap();
2645        for (run_id, verdict, summary, errors) in [
2646            ("reflection-failure", "failed", "health timeout", 1),
2647            ("reflection-success", "passed", "healthy deployment", 0),
2648        ] {
2649            db.start_experience_run(
2650                run_id,
2651                None,
2652                92,
2653                "web",
2654                "interactive",
2655                "deploy service",
2656                Some("os=linux"),
2657            )
2658            .unwrap();
2659            db.log_skill_activation("reflective-deploy", 92).unwrap();
2660            db.update_experience_metrics(run_id, 10, 5, 1, 2, errors, Some(1.0))
2661                .unwrap();
2662            db.finish_experience_run(run_id, "completed", Some(summary), 100)
2663                .unwrap();
2664            db.record_verifier_result(
2665                run_id,
2666                "deterministic",
2667                "health",
2668                verdict,
2669                1.0,
2670                Some(summary),
2671                None,
2672                None,
2673            )
2674            .unwrap();
2675        }
2676
2677        let comparisons = db.get_experience_comparisons(Some(92), 10).unwrap();
2678        assert_eq!(comparisons.len(), 1);
2679        assert_eq!(comparisons[0].task_family, "deployment");
2680        assert_eq!(comparisons[0].success_run_id, "reflection-success");
2681        assert_eq!(comparisons[0].failure_run_id, "reflection-failure");
2682        assert!(comparisons[0].minimal_difference.contains("tool_errors"));
2683
2684        let claims = db.get_learning_claims(Some(92), 10).unwrap();
2685        assert_eq!(claims.len(), 1);
2686        assert_eq!(claims[0].status, "candidate");
2687        assert_eq!(claims[0].evidence["success_run_id"], "reflection-success");
2688        assert!(claims[0].counterexamples.is_array());
2689
2690        let candidate = db
2691            .create_skill_candidate_from_claim(&claims[0].claim_id, 92)
2692            .unwrap();
2693        assert_eq!(candidate.base_version, 1);
2694        assert_eq!(candidate.candidate_version, 2);
2695        assert!(candidate.content.contains("Learned guardrail"));
2696        assert_eq!(
2697            db.get_skill_lifecycle("reflective-deploy")
2698                .unwrap()
2699                .unwrap()
2700                .active_version,
2701            1,
2702            "candidate creation and shadow evaluation must not activate it"
2703        );
2704
2705        let mut evaluation = None;
2706        for index in 0..3 {
2707            let pair = format!("pair-{index}");
2708            db.record_shadow_observation(
2709                &candidate.candidate_id,
2710                92,
2711                &pair,
2712                "baseline",
2713                "reflection-success",
2714                "passed",
2715                1.0,
2716                100,
2717                Some("baseline passed"),
2718            )
2719            .unwrap();
2720            evaluation = Some(
2721                db.record_shadow_observation(
2722                    &candidate.candidate_id,
2723                    92,
2724                    &pair,
2725                    "candidate",
2726                    "reflection-failure",
2727                    "failed",
2728                    0.8,
2729                    80,
2730                    Some("candidate regressed"),
2731                )
2732                .unwrap(),
2733            );
2734        }
2735        assert_eq!(evaluation.as_ref().unwrap().verdict, "failed");
2736        assert_eq!(evaluation.as_ref().unwrap().regression_count, 3);
2737
2738        for index in 0..3 {
2739            let pair = format!("pair-{index}");
2740            db.record_shadow_observation(
2741                &candidate.candidate_id,
2742                92,
2743                &pair,
2744                "baseline",
2745                "reflection-failure",
2746                "failed",
2747                1.0,
2748                100,
2749                Some("baseline failed"),
2750            )
2751            .unwrap();
2752            evaluation = Some(
2753                db.record_shadow_observation(
2754                    &candidate.candidate_id,
2755                    92,
2756                    &pair,
2757                    "candidate",
2758                    "reflection-success",
2759                    "passed",
2760                    0.8,
2761                    80,
2762                    Some("candidate passed"),
2763                )
2764                .unwrap(),
2765            );
2766        }
2767        let evaluation = evaluation.unwrap();
2768        assert_eq!(evaluation.sample_count, 3);
2769        assert_eq!(evaluation.verdict, "passed");
2770        assert!(evaluation.candidate_utility_lower_bound > 0.4);
2771        assert_eq!(
2772            db.get_skill_lifecycle("reflective-deploy")
2773                .unwrap()
2774                .unwrap()
2775                .active_version,
2776            1,
2777            "shadow pass still requires an explicit governed promotion"
2778        );
2779
2780        let promoted = db
2781            .promote_shadow_candidate(&candidate.candidate_id, 92)
2782            .unwrap();
2783        assert_eq!(promoted, ("reflective-deploy".to_string(), 2));
2784        assert_eq!(
2785            db.get_skill_lifecycle("reflective-deploy")
2786                .unwrap()
2787                .unwrap()
2788                .active_version,
2789            2
2790        );
2791
2792        for index in 0..2 {
2793            let run_id = format!("promoted-regression-{index}");
2794            db.start_experience_run(
2795                &run_id,
2796                None,
2797                92,
2798                "web",
2799                "interactive",
2800                "deploy service",
2801                Some("os=linux"),
2802            )
2803            .unwrap();
2804            db.log_skill_activation("reflective-deploy", 92).unwrap();
2805            db.record_verifier_result(
2806                &run_id,
2807                "deterministic",
2808                "health",
2809                "failed",
2810                1.0,
2811                Some("new regression"),
2812                None,
2813                None,
2814            )
2815            .unwrap();
2816        }
2817        let lifecycle = db
2818            .get_skill_lifecycle("reflective-deploy")
2819            .unwrap()
2820            .unwrap();
2821        assert_eq!(lifecycle.active_version, 1);
2822        assert_eq!(lifecycle.state, "trusted");
2823        assert!(lifecycle
2824            .state_reason
2825            .as_deref()
2826            .unwrap_or_default()
2827            .contains("automatic rollback"));
2828        let journal = db.get_learning_journal(Some(92), 50).unwrap();
2829        assert!(journal
2830            .iter()
2831            .any(|event| event.event_type == "claim_distilled"));
2832        assert!(journal
2833            .iter()
2834            .any(|event| event.event_type == "candidate_promoted"));
2835        assert!(journal
2836            .iter()
2837            .any(|event| event.event_type == "automatic_rollback"));
2838        assert!(db.clear_chat_memory(92).unwrap());
2839        assert!(db
2840            .get_experience_comparisons(Some(92), 10)
2841            .unwrap()
2842            .is_empty());
2843        assert!(db.get_learning_claims(Some(92), 10).unwrap().is_empty());
2844        assert!(db.get_skill_candidates(Some(92), 10).unwrap().is_empty());
2845        assert!(db.get_shadow_evaluations(Some(92)).unwrap().is_empty());
2846        cleanup(&dir);
2847    }
2848
2849    #[test]
2850    fn learning_substrate_does_not_assign_shared_success_to_multiple_skills() {
2851        let (db, dir) = test_db();
2852        for skill in ["planner", "executor"] {
2853            db.register_skill_version(skill, 1, "instructions", "agent-created")
2854                .unwrap();
2855        }
2856        db.start_experience_run(
2857            "shared-run",
2858            None,
2859            13,
2860            "web",
2861            "interactive",
2862            "combined task",
2863            Some("channel=web"),
2864        )
2865        .unwrap();
2866        db.log_skill_activation("planner", 13).unwrap();
2867        db.log_skill_activation("executor", 13).unwrap();
2868        db.record_verifier_result(
2869            "shared-run",
2870            "deterministic",
2871            "tests",
2872            "passed",
2873            1.0,
2874            Some("all tests passed"),
2875            None,
2876            None,
2877        )
2878        .unwrap();
2879        for skill in ["planner", "executor"] {
2880            assert_eq!(
2881                db.get_skill_lifecycle(skill).unwrap().unwrap().state,
2882                "candidate",
2883                "ambiguous shared credit must not promote either skill"
2884            );
2885        }
2886        assert!(db
2887            .get_skill_learning_summaries()
2888            .unwrap()
2889            .iter()
2890            .all(|summary| summary.skill_name != "planner" || summary.total_outcomes == 0));
2891        cleanup(&dir);
2892    }
2893
2894    #[test]
2895    fn learning_substrate_governance_policy_is_validated_and_persisted() {
2896        let (db, dir) = test_db();
2897        db.start_experience_run(
2898            "unique-run",
2899            None,
2900            15,
2901            "web",
2902            "interactive",
2903            "uniqueness",
2904            None,
2905        )
2906        .unwrap();
2907        assert!(db
2908            .start_experience_run(
2909                "unique-run",
2910                None,
2911                15,
2912                "web",
2913                "interactive",
2914                "replacement attempt",
2915                None,
2916            )
2917            .is_err());
2918        assert!(db
2919            .record_verifier_result(
2920                "missing-run",
2921                "human",
2922                "review",
2923                "passed",
2924                1.0,
2925                None,
2926                None,
2927                None,
2928            )
2929            .is_err());
2930        assert!(db
2931            .finish_experience_run("missing-run", "completed", None, 0)
2932            .is_err());
2933        let defaults = db.get_skill_governance_policy().unwrap();
2934        assert_eq!(defaults.trial_min_outcomes, 3);
2935        assert_eq!(defaults.utility_confidence_z, 1.96);
2936        assert_eq!(defaults.trial_promote_utility_lower_bound, 0.4);
2937        assert_eq!(defaults.failure_pattern_min_failures, 2);
2938        assert_eq!(defaults.failure_pattern_cooldown_hours, 24);
2939        assert_eq!(defaults.failure_pattern_recovery_successes, 2);
2940        assert_eq!(defaults.shadow_min_samples, 3);
2941        assert_eq!(defaults.shadow_promote_utility_margin, 0.05);
2942        assert_eq!(defaults.shadow_max_cost_ratio, 1.2);
2943        assert_eq!(defaults.shadow_max_regressions, 0);
2944
2945        let mut invalid = defaults.clone();
2946        invalid.trial_degrade_rate = invalid.trial_promote_rate;
2947        assert!(db.update_skill_governance_policy(&invalid).is_err());
2948        let mut invalid_confidence = defaults.clone();
2949        invalid_confidence.utility_confidence_z = -1.0;
2950        assert!(db
2951            .update_skill_governance_policy(&invalid_confidence)
2952            .is_err());
2953        let mut invalid_cooldown = defaults.clone();
2954        invalid_cooldown.failure_pattern_cooldown_hours = 0;
2955        assert!(db
2956            .update_skill_governance_policy(&invalid_cooldown)
2957            .is_err());
2958        let mut invalid_shadow_cost = defaults.clone();
2959        invalid_shadow_cost.shadow_max_cost_ratio = 0.9;
2960        assert!(db
2961            .update_skill_governance_policy(&invalid_shadow_cost)
2962            .is_err());
2963
2964        let mut updated = defaults;
2965        updated.candidate_failures_to_degrade = 3;
2966        updated.trial_min_outcomes = 4;
2967        updated.trusted_min_outcomes = 6;
2968        updated.utility_confidence_z = 1.645;
2969        updated.trial_promote_utility_lower_bound = 0.3;
2970        updated.failure_pattern_min_failures = 3;
2971        updated.failure_pattern_cooldown_hours = 12;
2972        updated.failure_pattern_recovery_successes = 1;
2973        updated.shadow_min_samples = 4;
2974        updated.shadow_promote_utility_margin = 0.1;
2975        updated.shadow_max_cost_ratio = 1.5;
2976        updated.shadow_max_regressions = 1;
2977        db.update_skill_governance_policy(&updated).unwrap();
2978        let saved = db.get_skill_governance_policy().unwrap();
2979        assert_eq!(saved.candidate_failures_to_degrade, 3);
2980        assert_eq!(saved.utility_confidence_z, 1.645);
2981        assert_eq!(saved.trial_promote_utility_lower_bound, 0.3);
2982        assert_eq!(saved.failure_pattern_min_failures, 3);
2983        assert_eq!(saved.failure_pattern_cooldown_hours, 12);
2984        assert_eq!(saved.failure_pattern_recovery_successes, 1);
2985        assert_eq!(saved.shadow_min_samples, 4);
2986        assert_eq!(saved.shadow_promote_utility_margin, 0.1);
2987        assert_eq!(saved.shadow_max_cost_ratio, 1.5);
2988        assert_eq!(saved.shadow_max_regressions, 1);
2989        cleanup(&dir);
2990    }
2991
2992    #[test]
2993    fn migration_39_creates_failure_patterns_and_rejection_audit() {
2994        let (db, dir) = test_db();
2995        db.register_skill_version("legacy-failure", 1, "deploy", "built-in")
2996            .unwrap();
2997        for index in 0..2 {
2998            let run_id = format!("legacy-failure-{index}");
2999            db.start_experience_run(
3000                &run_id,
3001                None,
3002                81,
3003                "web",
3004                "interactive",
3005                "deploy service",
3006                Some("os=linux"),
3007            )
3008            .unwrap();
3009            db.log_skill_activation("legacy-failure", 81).unwrap();
3010            db.record_verifier_result(
3011                &run_id,
3012                "deterministic",
3013                "health",
3014                "failed",
3015                1.0,
3016                Some("network connect failed"),
3017                None,
3018                None,
3019            )
3020            .unwrap();
3021        }
3022        {
3023            let conn = db.lock_conn();
3024            conn.execute_batch(
3025                "DROP TABLE skill_failure_pattern_evidence;
3026                 DROP TABLE skill_failure_patterns;
3027                 DROP TABLE experience_retrieval_rejections;",
3028            )
3029            .unwrap();
3030            set_schema_version(&conn, 38).unwrap();
3031            apply_schema_migrations(&conn).unwrap();
3032            let patterns: i64 = conn
3033                .query_row(
3034                    "SELECT COUNT(*) FROM sqlite_master
3035                     WHERE type='table' AND name='skill_failure_patterns'",
3036                    [],
3037                    |row| row.get(0),
3038                )
3039                .unwrap();
3040            let rejections: i64 = conn
3041                .query_row(
3042                    "SELECT COUNT(*) FROM sqlite_master
3043                     WHERE type='table' AND name='experience_retrieval_rejections'",
3044                    [],
3045                    |row| row.get(0),
3046                )
3047                .unwrap();
3048            assert_eq!(patterns, 1);
3049            assert_eq!(rejections, 1);
3050            assert_eq!(get_schema_version(&conn).unwrap(), SCHEMA_VERSION_CURRENT);
3051        }
3052        let backfilled = db
3053            .get_skill_failure_patterns(Some("legacy-failure"))
3054            .unwrap();
3055        assert_eq!(backfilled.len(), 1);
3056        assert_eq!(backfilled[0].failure_count, 2);
3057        assert_eq!(backfilled[0].error_category, "network");
3058        assert_eq!(backfilled[0].state, "active");
3059        cleanup(&dir);
3060    }
3061
3062    #[test]
3063    fn utility_lower_bound_gates_promotion_and_summaries_are_task_stratified() {
3064        let (db, dir) = test_db();
3065        db.register_skill_version("bounded", 1, "instructions", "agent-created")
3066            .unwrap();
3067        let mut policy = db.get_skill_governance_policy().unwrap();
3068        policy.trial_promote_rate = 0.6;
3069        policy.trial_promote_utility_lower_bound = 0.5;
3070        db.update_skill_governance_policy(&policy).unwrap();
3071
3072        for index in 0..3 {
3073            let run_id = format!("bounded-pass-{index}");
3074            db.start_experience_run(
3075                &run_id,
3076                None,
3077                61,
3078                "web",
3079                "interactive",
3080                "deploy service safely",
3081                Some("env=prod"),
3082            )
3083            .unwrap();
3084            db.log_skill_activation("bounded", 61).unwrap();
3085            db.record_verifier_result(
3086                &run_id,
3087                "deterministic",
3088                "check",
3089                "passed",
3090                1.0,
3091                None,
3092                None,
3093                None,
3094            )
3095            .unwrap();
3096            db.finish_experience_run(&run_id, "completed", None, 1)
3097                .unwrap();
3098        }
3099        assert_eq!(
3100            db.get_skill_lifecycle("bounded").unwrap().unwrap().state,
3101            "trial",
3102            "three perfect samples remain too uncertain at z=1.96"
3103        );
3104
3105        db.start_experience_run(
3106            "bounded-pass-3",
3107            None,
3108            61,
3109            "web",
3110            "interactive",
3111            "deploy service safely",
3112            Some("env=prod"),
3113        )
3114        .unwrap();
3115        db.log_skill_activation("bounded", 61).unwrap();
3116        db.record_verifier_result(
3117            "bounded-pass-3",
3118            "deterministic",
3119            "check",
3120            "passed",
3121            1.0,
3122            None,
3123            None,
3124            None,
3125        )
3126        .unwrap();
3127        db.finish_experience_run("bounded-pass-3", "completed", None, 1)
3128            .unwrap();
3129        assert_eq!(
3130            db.get_skill_lifecycle("bounded").unwrap().unwrap().state,
3131            "trusted"
3132        );
3133
3134        let summaries = db
3135            .get_skill_task_utility_summaries(Some("bounded"))
3136            .unwrap();
3137        assert_eq!(summaries.len(), 1);
3138        assert_eq!(summaries[0].task_family, "deployment");
3139        assert_eq!(summaries[0].total_outcomes, 4);
3140        assert!(summaries[0].utility_lower_bound > 0.5);
3141        cleanup(&dir);
3142    }
3143}