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