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