Skip to main content

innate_core/kb/
lifecycle.rs

1use super::*;
2
3impl KnowledgeBase {
4    pub fn add(
5        &self,
6        content: &str,
7        kind: &str,
8        trigger_desc: Option<&str>,
9        anti_trigger_desc: Option<&str>,
10        source: &str,
11        skill_name: Option<&str>,
12    ) -> Result<String> {
13        self.add_with_deps(
14            content,
15            kind,
16            trigger_desc,
17            anti_trigger_desc,
18            source,
19            skill_name,
20            &[],
21        )
22    }
23
24    /// Full-form writer: persist a chunk, its vectors, and all declared
25    /// dependencies in a **single transaction**. Each dep is `(dst_chunk_id,
26    /// kind)`; `kind` ∈ {`soft`,`hard`}. Dependency targets are validated to
27    /// exist *inside* the transaction, so a bad dependency rolls back the whole
28    /// write — the chunk is never persisted on its own.
29    #[allow(clippy::too_many_arguments)]
30    pub fn add_with_deps(
31        &self,
32        content: &str,
33        kind: &str,
34        trigger_desc: Option<&str>,
35        anti_trigger_desc: Option<&str>,
36        source: &str,
37        skill_name: Option<&str>,
38        deps: &[(String, String)],
39    ) -> Result<String> {
40        if !matches!(kind, "note" | "skill") {
41            return Err(InnateError::InvalidState(format!("invalid kind: {kind}")));
42        }
43        for (_, dep_kind) in deps {
44            if !matches!(dep_kind.as_str(), "soft" | "hard") {
45                return Err(InnateError::InvalidState(format!(
46                    "invalid dependency kind: {dep_kind} (expected soft|hard)"
47                )));
48            }
49        }
50        if !matches!(source, "chat" | "manual" | "doc" | "agent") {
51            return Err(InnateError::InvalidState(format!(
52                "invalid source: {source}"
53            )));
54        }
55
56        let (content, action) = self.sanitize_content(content);
57        if action == SanitizeAction::Discard {
58            return Ok(String::new());
59        }
60
61        let trigger_clean = trigger_desc.and_then(|t| {
62            let (cleaned, act) = self.sanitizer.sanitize(t);
63            if act == SanitizeAction::Discard {
64                None
65            } else {
66                Some(cleaned)
67            }
68        });
69        let anti_trigger_clean = anti_trigger_desc.and_then(|t| {
70            let (cleaned, act) = self.sanitizer.sanitize(t);
71            if act == SanitizeAction::Discard {
72                None
73            } else {
74                Some(cleaned)
75            }
76        });
77
78        let h = content_hash(&content);
79        if self.storage.is_hash_invalidated(&h)? {
80            return Err(InnateError::InvalidState(
81                "content hash is invalidated".into(),
82            ));
83        }
84
85        // Idempotency check
86        let existing = self.storage.query_chunks_params(
87            "SELECT id FROM chunks WHERE content_hash=? AND origin!='spark' AND state IN ('active','pending') ORDER BY created_at ASC LIMIT 1",
88            rusqlite::params![h],
89        )?;
90        if let Some(e) = existing.first() {
91            if let Some(id) = e.get("id").and_then(Value::as_str).map(str::to_string) {
92                // Content already exists. Don't silently drop newly-declared
93                // dependencies: merge them into the existing chunk in one
94                // transaction (edge insert is idempotent via INSERT OR IGNORE,
95                // targets validated as in the fresh-write path).
96                if !deps.is_empty() {
97                    self.storage.begin_immediate()?;
98                    let merge = (|| -> Result<()> {
99                        for (dst, dep_kind) in deps {
100                            if self.storage.get_chunk(dst)?.is_none() {
101                                return Err(InnateError::ChunkNotFound(format!(
102                                    "dependency target not found: {dst}"
103                                )));
104                            }
105                            self.storage.insert_dep(&id, dst, dep_kind, None)?;
106                        }
107                        self.storage.commit()
108                    })();
109                    if merge.is_err() {
110                        let _ = self.storage.rollback();
111                    }
112                    merge?;
113                }
114                return Ok(id);
115            }
116        }
117
118        let now = utc_now_iso();
119        let chunk_id = gen_uuid();
120        let redacted = action == SanitizeAction::Redact;
121
122        let (origin, state, conf, prot, init_state_reason) = if source == "agent" {
123            (
124                "captured",
125                "pending",
126                if redacted { 0.4 } else { 0.60 },
127                0,
128                "init:captured_agent",
129            )
130        } else if kind == "skill" {
131            (
132                "installed",
133                "active",
134                if redacted { 0.4 } else { 0.85 },
135                1,
136                "init:installed",
137            )
138        } else {
139            (
140                "captured",
141                "active",
142                if redacted { 0.4 } else { 0.60 },
143                0,
144                "init:captured",
145            )
146        };
147
148        // Embedding — fall back to embedding_pending on failure.
149        let trigger_str = trigger_clean.as_deref().unwrap_or(&content);
150        let (cvec, tvec, embed_ver, final_state_reason) = match (
151            self.embedding.embed_content(&content),
152            self.embedding.embed_trigger(trigger_str),
153        ) {
154            (Ok(cv), Ok(tv)) => (cv, tv, 1i64, init_state_reason.to_string()),
155            _ => (
156                vec![],
157                vec![],
158                0i64,
159                format!("embedding_pending:target={state}"),
160            ),
161        };
162
163        let tokens = estimate_tokens(&content) as i64;
164        let row = ChunkRow {
165            id: chunk_id.clone(),
166            skill_name: skill_name.map(str::to_string),
167            content: content.clone(),
168            trigger_desc: trigger_clean.clone(),
169            anti_trigger_desc: anti_trigger_clean.clone(),
170            content_hash: h,
171            token_count: Some(tokens),
172            origin: origin.to_string(),
173            source: Some(source.to_string()),
174            protected: prot,
175            state: state.to_string(),
176            state_reason: Some(final_state_reason),
177            confidence: conf,
178            confidence_reason: Some(format!("init:{origin}")),
179            version: 1,
180            embed_version: embed_ver,
181            created_at: now.clone(),
182            updated_at: now.clone(),
183            ..Default::default()
184        };
185
186        self.storage.begin_immediate()?;
187        let result = (|| -> Result<()> {
188            self.storage.insert_chunk(&row)?;
189            if embed_ver > 0 {
190                self.store_vec_content(&chunk_id, &cvec)?;
191                self.store_vec_trigger(&chunk_id, &tvec)?;
192            }
193            // Dependencies are validated and written in the SAME transaction: a
194            // missing target aborts the whole write so the chunk never lands
195            // alone (no foreign keys, so existence is checked here explicitly).
196            for (dst, dep_kind) in deps {
197                if self.storage.get_chunk(dst)?.is_none() {
198                    return Err(InnateError::ChunkNotFound(format!(
199                        "dependency target not found: {dst}"
200                    )));
201                }
202                self.storage.insert_dep(&chunk_id, dst, dep_kind, None)?;
203            }
204            self.storage.commit()
205        })();
206        if result.is_err() {
207            let _ = self.storage.rollback();
208        }
209        result?;
210        Ok(chunk_id)
211    }
212
213    /// Declare that chunk `src` depends on chunk `dst`.
214    ///
215    /// `kind` is `"hard"` (fail-closed: if `dst` is unavailable or archived at
216    /// recall time the whole seed is dropped) or `"soft"` (a recall-time
217    /// ranking bonus). Both chunks must exist. Idempotent — re-declaring the
218    /// same edge is a no-op (`INSERT OR IGNORE`).
219    pub fn add_dependency(&self, src: &str, dst: &str, kind: &str) -> Result<()> {
220        if !matches!(kind, "soft" | "hard") {
221            return Err(InnateError::InvalidState(format!(
222                "invalid dependency kind: {kind} (expected soft|hard)"
223            )));
224        }
225        if self.storage.get_chunk(src)?.is_none() {
226            return Err(InnateError::ChunkNotFound(format!(
227                "dependency source not found: {src}"
228            )));
229        }
230        if self.storage.get_chunk(dst)?.is_none() {
231            return Err(InnateError::ChunkNotFound(format!(
232                "dependency target not found: {dst}"
233            )));
234        }
235        self.storage.insert_dep(src, dst, kind, None)
236    }
237
238    // ------------------------------------------------------------------
239    // Public API 4: spark
240    // ------------------------------------------------------------------
241
242    pub fn spark(
243        &self,
244        content: &str,
245        trigger_desc: Option<&str>,
246        anti_trigger_desc: Option<&str>,
247    ) -> Result<String> {
248        let (content, action) = self.sanitize_content(content);
249        if action == SanitizeAction::Discard {
250            return Ok(String::new());
251        }
252
253        let trigger_clean = trigger_desc.and_then(|t| {
254            let (cleaned, act) = self.sanitizer.sanitize(t);
255            if act == SanitizeAction::Discard {
256                None
257            } else {
258                Some(cleaned)
259            }
260        });
261        let anti_trigger_clean = anti_trigger_desc.and_then(|t| {
262            let (cleaned, act) = self.sanitizer.sanitize(t);
263            if act == SanitizeAction::Discard {
264                None
265            } else {
266                Some(cleaned)
267            }
268        });
269
270        let h = content_hash(&content);
271        if self.storage.is_hash_invalidated(&h)? {
272            return Err(InnateError::InvalidState(
273                "content hash is invalidated".into(),
274            ));
275        }
276
277        // Quick related recall (trace=false, no recursion risk)
278        let related: Vec<String> = self
279            .recall(RecallParams {
280                query: &content,
281                budget: 2000,
282                top: Some(5),
283                source: "sdk",
284                ..Default::default()
285            })
286            .map(|r| {
287                r.knowledge
288                    .iter()
289                    .filter_map(|c| c["id"].as_str().map(str::to_string))
290                    .collect()
291            })
292            .unwrap_or_default();
293
294        let now = utc_now_iso();
295        let chunk_id = gen_uuid();
296        let tokens = estimate_tokens(&content) as i64;
297
298        let trigger_str = trigger_clean.as_deref().unwrap_or(&content);
299        let (cvec, tvec, embed_ver, state_reason) = match (
300            self.embedding.embed_content(&content),
301            self.embedding.embed_trigger(trigger_str),
302        ) {
303            (Ok(cv), Ok(tv)) => (cv, tv, 1i64, "init:spark".to_string()),
304            _ => (
305                vec![],
306                vec![],
307                0i64,
308                "embedding_pending:target=active".to_string(),
309            ),
310        };
311
312        let row = ChunkRow {
313            id: chunk_id.clone(),
314            content: content.clone(),
315            trigger_desc: trigger_clean.clone(),
316            anti_trigger_desc: anti_trigger_clean.clone(),
317            content_hash: h,
318            token_count: Some(tokens),
319            origin: "spark".to_string(),
320            maturity: Some("seed".to_string()),
321            related_ids: if related.is_empty() {
322                None
323            } else {
324                Some(related.join(","))
325            },
326            state: "active".to_string(),
327            state_reason: Some(state_reason),
328            confidence: 0.5,
329            version: 1,
330            embed_version: embed_ver,
331            created_at: now.clone(),
332            updated_at: now.clone(),
333            ..Default::default()
334        };
335
336        self.storage.begin_immediate()?;
337        let result = (|| -> Result<()> {
338            self.storage.insert_chunk(&row)?;
339            if embed_ver > 0 {
340                self.store_vec_content(&chunk_id, &cvec)?;
341                self.store_vec_trigger(&chunk_id, &tvec)?;
342            }
343            self.storage.commit()
344        })();
345        if result.is_err() {
346            let _ = self.storage.rollback();
347        }
348        result?;
349        Ok(chunk_id)
350    }
351
352    // ------------------------------------------------------------------
353    // Public API 5: mature_spark / promote_spark / drop_spark
354    // ------------------------------------------------------------------
355
356    pub fn mature_spark(&self, spark_id: &str, to: &str) -> Result<()> {
357        let chunk = self
358            .storage
359            .get_chunk(spark_id)?
360            .ok_or_else(|| InnateError::ChunkNotFound(spark_id.to_string()))?;
361        if chunk.get("origin").and_then(Value::as_str) != Some("spark") {
362            return Err(InnateError::ChunkNotFound(spark_id.to_string()));
363        }
364        let current = chunk
365            .get("maturity")
366            .and_then(Value::as_str)
367            .unwrap_or("seed");
368        let valid_next: &[&str] = match current {
369            "seed" => &["sprouting"],
370            "sprouting" => &["incubating"],
371            _ => {
372                return Err(InnateError::InvalidState(format!(
373                    "spark {spark_id} already {current}"
374                )))
375            }
376        };
377        if current == to {
378            return Ok(());
379        }
380        if !valid_next.contains(&to) {
381            return Err(InnateError::InvalidState(format!(
382                "invalid spark maturity transition: {current} -> {to}"
383            )));
384        }
385        let now = utc_now_iso();
386        self.storage.begin_immediate()?;
387        let result = self
388            .storage
389            .query_chunks_params(
390                "UPDATE chunks SET maturity=?, updated_at=? WHERE id=?",
391                rusqlite::params![to, now, spark_id],
392            )
393            .and_then(|_| self.storage.commit());
394        if result.is_err() {
395            let _ = self.storage.rollback();
396        }
397        result.map(|_| ())
398    }
399
400    pub fn promote_spark(&self, spark_id: &str, to: &str) -> Result<String> {
401        let spark = self
402            .storage
403            .get_chunk(spark_id)?
404            .ok_or_else(|| InnateError::ChunkNotFound(spark_id.to_string()))?;
405        if spark.get("origin").and_then(Value::as_str) != Some("spark") {
406            return Err(InnateError::ChunkNotFound(spark_id.to_string()));
407        }
408        let maturity = spark.get("maturity").and_then(Value::as_str).unwrap_or("");
409        if maturity == "promoted" || maturity == "dropped" {
410            return Err(InnateError::InvalidState(format!(
411                "spark {spark_id} already {maturity}"
412            )));
413        }
414        if !matches!(to, "note" | "skill") {
415            return Err(InnateError::InvalidState(format!(
416                "invalid spark promotion target: {to}"
417            )));
418        }
419
420        let content = spark.get("content").and_then(Value::as_str).unwrap_or("");
421        let (content, action) = self.sanitize_content(content);
422        if action == SanitizeAction::Discard {
423            return Err(InnateError::InvalidState(
424                "sanitize discard on promote".into(),
425            ));
426        }
427
428        let promoted_hash = content_hash(&content);
429        if self.storage.is_hash_invalidated(&promoted_hash)? {
430            return Err(InnateError::InvalidState(
431                "spark content hash is invalidated".into(),
432            ));
433        }
434
435        let now = utc_now_iso();
436
437        // Idempotency: existing non-spark chunk with same hash
438        let existing = self.storage.query_chunks_params(
439            "SELECT id FROM chunks WHERE content_hash=? AND origin!='spark' AND state IN ('active','pending') ORDER BY created_at ASC LIMIT 1",
440            rusqlite::params![promoted_hash],
441        )?;
442        if let Some(e) = existing.first() {
443            if let Some(id) = e.get("id").and_then(Value::as_str) {
444                let id = id.to_string();
445                self.storage.begin_immediate()?;
446                let result = self
447                    .storage
448                    .query_chunks_params(
449                        "UPDATE chunks SET maturity='promoted', updated_at=? WHERE id=?",
450                        rusqlite::params![now, spark_id],
451                    )
452                    .and_then(|_| self.storage.commit());
453                if result.is_err() {
454                    let _ = self.storage.rollback();
455                    result?;
456                }
457                return Ok(id);
458            }
459        }
460
461        let (state, conf, prot, origin, state_reason) = if to == "skill" {
462            ("active", 0.85, 1, "installed", "init:installed")
463        } else {
464            ("active", 0.60, 0, "captured", "init:captured")
465        };
466
467        let conf = if action == SanitizeAction::Redact {
468            0.4_f64
469        } else {
470            conf
471        };
472        let new_id = gen_uuid();
473        let trigger = spark.get("trigger_desc").and_then(Value::as_str);
474        let anti = spark.get("anti_trigger_desc").and_then(Value::as_str);
475
476        let row = ChunkRow {
477            id: new_id.clone(),
478            content: content.clone(),
479            trigger_desc: trigger.map(str::to_string),
480            anti_trigger_desc: anti.map(str::to_string),
481            content_hash: promoted_hash,
482            token_count: Some(estimate_tokens(&content) as i64),
483            origin: origin.to_string(),
484            source: Some("manual".to_string()),
485            protected: prot,
486            state: state.to_string(),
487            state_reason: Some(state_reason.to_string()),
488            confidence: conf,
489            confidence_reason: Some("manual_set".to_string()),
490            parent_id: Some(spark_id.to_string()),
491            version: 1,
492            embed_version: 1,
493            created_at: now.clone(),
494            updated_at: now.clone(),
495            ..Default::default()
496        };
497
498        let cvec = self.embedding.embed_content(&content)?;
499        let tvec = self.embedding.embed_trigger(trigger.unwrap_or(&content))?;
500
501        self.storage.begin_immediate()?;
502        let result = (|| -> Result<()> {
503            self.storage.insert_chunk(&row)?;
504            self.store_vec_content(&new_id, &cvec)?;
505            self.store_vec_trigger(&new_id, &tvec)?;
506            self.storage.query_chunks_params(
507                "UPDATE chunks SET maturity='promoted', updated_at=? WHERE id=?",
508                rusqlite::params![now, spark_id],
509            )?;
510            self.storage.commit()
511        })();
512        if result.is_err() {
513            let _ = self.storage.rollback();
514        }
515        result?;
516        Ok(new_id)
517    }
518
519    pub fn drop_spark(&self, spark_id: &str, reason: &str) -> Result<()> {
520        let spark = self
521            .storage
522            .get_chunk(spark_id)?
523            .ok_or_else(|| InnateError::ChunkNotFound(spark_id.to_string()))?;
524        if spark.get("origin").and_then(Value::as_str) != Some("spark") {
525            return Err(InnateError::ChunkNotFound(spark_id.to_string()));
526        }
527        let maturity = spark.get("maturity").and_then(Value::as_str).unwrap_or("");
528        if maturity == "promoted" {
529            return Err(InnateError::InvalidState(format!(
530                "spark {spark_id} already promoted"
531            )));
532        }
533        if maturity == "dropped" {
534            return Ok(());
535        }
536        let now = utc_now_iso();
537        let reason_str = if reason.is_empty() {
538            "dropped".to_string()
539        } else {
540            format!("dropped:{reason}")
541        };
542        self.storage.begin_immediate()?;
543        let result = self
544            .storage
545            .query_chunks_params(
546                "UPDATE chunks SET maturity='dropped', state_reason=?, updated_at=? WHERE id=?",
547                rusqlite::params![reason_str, now, spark_id],
548            )
549            .and_then(|_| self.storage.commit());
550        if result.is_err() {
551            let _ = self.storage.rollback();
552        }
553        result.map(|_| ())
554    }
555
556    // ------------------------------------------------------------------
557    // Public API 6: approve / archive / invalidate / restore
558    // ------------------------------------------------------------------
559
560    pub fn approve(&self, chunk_id: &str) -> Result<()> {
561        let chunk = self
562            .storage
563            .get_chunk(chunk_id)?
564            .ok_or_else(|| InnateError::ChunkNotFound(chunk_id.to_string()))?;
565        if chunk.get("origin").and_then(Value::as_str) == Some("spark") {
566            return Err(InnateError::InvalidState(
567                "spark lifecycle uses promote_spark() or invalidate()".into(),
568            ));
569        }
570        if chunk.get("state").and_then(Value::as_str) == Some("active") {
571            return Ok(());
572        }
573        if chunk.get("state").and_then(Value::as_str) != Some("pending") {
574            return Err(InnateError::InvalidState(
575                "approve requires pending chunk".into(),
576            ));
577        }
578        let now = utc_now_iso();
579        self.storage.begin_immediate()?;
580        let result = (|| -> Result<()> {
581            self.storage
582                .update_chunk_state(chunk_id, "active", Some("approved"), &now)?;
583            self.storage.query_chunks_params(
584                "UPDATE chunks SET confidence_reason='manual_set', updated_at=? WHERE id=?",
585                rusqlite::params![now, chunk_id],
586            )?;
587            self.storage.commit()
588        })();
589        if result.is_err() {
590            let _ = self.storage.rollback();
591        }
592        result
593    }
594
595    pub fn archive(&self, chunk_id: &str, reason: &str) -> Result<()> {
596        let chunk = self
597            .storage
598            .get_chunk(chunk_id)?
599            .ok_or_else(|| InnateError::ChunkNotFound(chunk_id.to_string()))?;
600        if chunk.get("origin").and_then(Value::as_str) == Some("spark") {
601            return Err(InnateError::InvalidState(
602                "spark lifecycle uses drop_spark() or invalidate()".into(),
603            ));
604        }
605        let now = utc_now_iso();
606        self.storage.begin_immediate()?;
607        let result = self
608            .storage
609            .update_chunk_state(chunk_id, "archived", Some(reason), &now)
610            .and_then(|_| self.storage.commit());
611        if result.is_err() {
612            let _ = self.storage.rollback();
613        }
614        result
615    }
616
617    pub fn invalidate(&self, chunk_id: &str, reason: &str) -> Result<()> {
618        let chunk = self
619            .storage
620            .get_chunk(chunk_id)?
621            .ok_or_else(|| InnateError::ChunkNotFound(chunk_id.to_string()))?;
622        let h = chunk
623            .get("content_hash")
624            .and_then(Value::as_str)
625            .unwrap_or("")
626            .to_string();
627        let now = utc_now_iso();
628        let reason_str = if reason.is_empty() {
629            "invalidated".to_string()
630        } else {
631            format!("invalidated:{reason}")
632        };
633
634        self.storage.begin_immediate()?;
635        let result = (|| -> Result<()> {
636            self.storage.query_chunks_params(
637                "UPDATE chunks
638                 SET state='archived', confidence=0.0, confidence_base=0.0,
639                     confidence_reason='invalidated', state_reason=?,
640                     state_updated_at=?, updated_at=?
641                 WHERE id=?",
642                rusqlite::params![reason_str, now, now, chunk_id],
643            )?;
644            self.storage.query_chunks_params(
645                "UPDATE chunks
646                 SET state='archived', confidence=0.0, confidence_base=0.0,
647                     confidence_reason='invalidated',
648                     state_reason='invalidated:same_hash',
649                     state_updated_at=?, updated_at=?
650                 WHERE content_hash=? AND id!=?",
651                rusqlite::params![now, now, h, chunk_id],
652            )?;
653            self.storage.conn_execute(
654                "DELETE FROM confidence_evidence
655                 WHERE chunk_id IN (SELECT id FROM chunks WHERE content_hash=?)",
656                rusqlite::params![h],
657            )?;
658            self.storage
659                .insert_invalidated_hash(&h, Some(reason), &now)?;
660            self.storage.commit()
661        })();
662        if result.is_err() {
663            let _ = self.storage.rollback();
664        }
665        result
666    }
667
668    pub fn restore(&self, chunk_id: &str) -> Result<()> {
669        let chunk = self
670            .storage
671            .get_chunk(chunk_id)?
672            .ok_or_else(|| InnateError::ChunkNotFound(chunk_id.to_string()))?;
673        let state = chunk.get("state").and_then(Value::as_str).unwrap_or("");
674        if state == "active" {
675            return Ok(());
676        }
677        if state != "archived" {
678            return Err(InnateError::InvalidState(
679                "restore requires archived chunk".into(),
680            ));
681        }
682        let was_invalidated = chunk
683            .get("state_reason")
684            .and_then(Value::as_str)
685            .map(|r| r.starts_with("invalidated"))
686            .unwrap_or(false);
687        let h = chunk
688            .get("content_hash")
689            .and_then(Value::as_str)
690            .unwrap_or("")
691            .to_string();
692        let now = utc_now_iso();
693
694        self.storage.begin_immediate()?;
695        let result = (|| -> Result<()> {
696            self.storage
697                .update_chunk_state(chunk_id, "active", Some("restore"), &now)?;
698            if was_invalidated {
699                self.storage.query_chunks_params(
700                    "DELETE FROM invalidated_hashes WHERE content_hash=?",
701                    rusqlite::params![h],
702                )?;
703            }
704            self.storage.query_chunks_params(
705                "UPDATE chunks
706                 SET confidence_base=0.5, confidence=0.5,
707                     confidence_reason='restore',
708                     selected_count=0, selected_count_base=0,
709                     used_count=0, used_count_base=0,
710                     used_success_count=0, used_success_count_base=0,
711                     success_trace_ids_count=0,
712                     last_used_at=NULL, last_used_base=NULL,
713                     last_success_at=NULL, last_decayed_at=NULL,
714                     evidence_cutoff_at=?, updated_at=?
715                 WHERE id=?",
716                rusqlite::params![now, now, chunk_id],
717            )?;
718            self.storage.conn_execute(
719                "DELETE FROM confidence_evidence WHERE chunk_id=?",
720                rusqlite::params![chunk_id],
721            )?;
722            self.storage.conn_execute(
723                "DELETE FROM chunk_success_traces WHERE chunk_id=?",
724                rusqlite::params![chunk_id],
725            )?;
726            self.storage.conn_execute(
727                "DELETE FROM chunk_context_stats_base WHERE chunk_id=?",
728                rusqlite::params![chunk_id],
729            )?;
730            self.storage.conn_execute(
731                "DELETE FROM chunk_context_stats WHERE chunk_id=?",
732                rusqlite::params![chunk_id],
733            )?;
734            self.storage.conn_execute(
735                "UPDATE governance_proposals
736                 SET state='rejected', reason=reason || '; restored by user', updated_at=?
737                 WHERE chunk_id=? AND state IN ('pending','accepted')",
738                rusqlite::params![now, chunk_id],
739            )?;
740            self.storage.commit()
741        })();
742        if result.is_err() {
743            let _ = self.storage.rollback();
744        }
745        result
746    }
747
748    // ------------------------------------------------------------------
749    // Public API 7: evolve
750    // ------------------------------------------------------------------
751}