store: protected nodes, explicit provenance in mutations

- Add protected_nodes config list - blocks delete/rename of core nodes
- Remove current_provenance() env var lookup, pass provenance explicitly
- delete_node, rename_node, set_link_strength now take provenance param
- Fix new_relation calls in admin.rs to pass "system" provenance

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-15 01:40:18 -04:00
parent cc29cd2225
commit 6ec7fcb777
4 changed files with 159 additions and 38 deletions

View file

@ -56,8 +56,30 @@ pub async fn cmd_init() -> Result<()> {
}
pub async fn cmd_fsck() -> Result<()> {
// Check/repair capnp log integrity first
store::fsck()?;
// Full fsck: verify capnp logs and compare index with rebuilt
let report = store::fsck_full()?;
if report.capnp_repaired {
eprintln!("capnp log was repaired (corrupt messages truncated)");
}
if !report.zombies.is_empty() {
eprintln!("\nZOMBIE entries (in index but not in log):");
for key in &report.zombies {
eprintln!(" {}", key);
}
}
if !report.missing.is_empty() {
eprintln!("\nMISSING entries (in log but not in index):");
for key in &report.missing {
eprintln!(" {}", key);
}
}
if !report.is_clean() {
eprintln!("\nTo repair: poc-memory admin repair-index");
}
let store = memory::access_local()?;
@ -110,6 +132,12 @@ pub async fn cmd_fsck() -> Result<()> {
Ok(())
}
pub async fn cmd_repair_index() -> Result<()> {
store::repair_index()?;
println!("Index repaired successfully.");
Ok(())
}
pub async fn cmd_dedup(apply: bool) -> Result<()> {
use std::collections::HashMap;
@ -255,6 +283,7 @@ pub async fn cmd_dedup(apply: bool) -> Result<()> {
store::RelationType::from_u8(rel_type), strength,
&uuid_to_key.get(&old_src).cloned().unwrap_or_default(),
&uuid_to_key.get(&old_tgt).cloned().unwrap_or_default(),
"system",
);
tombstone.deleted = true;
tombstone.version = 2;
@ -263,6 +292,7 @@ pub async fn cmd_dedup(apply: bool) -> Result<()> {
new_src, new_tgt,
store::RelationType::from_u8(rel_type), strength,
&src_key, &tgt_key,
"system",
);
redirected.version = 2;
@ -299,6 +329,7 @@ pub async fn cmd_dedup(apply: bool) -> Result<()> {
src, tgt,
store::RelationType::from_u8(rel_type), strength,
&src_key, &tgt_key,
"system",
);
tombstone.deleted = true;
tombstone.version = 2;

View file

@ -78,6 +78,9 @@ pub struct Config {
#[serde(deserialize_with = "deserialize_path")]
pub projects_dir: PathBuf,
pub core_nodes: Vec<String>,
/// Nodes that cannot be deleted or renamed without --force
#[serde(default)]
pub protected_nodes: Vec<String>,
pub journal_days: u32,
pub journal_max: usize,
pub context_groups: Vec<ContextGroup>,
@ -146,6 +149,7 @@ impl Default for Config {
identity_dir: home.join(".consciousness/identity"),
projects_dir: home.join(".claude/projects"),
core_nodes: vec!["identity".to_string(), "core-practices".to_string()],
protected_nodes: Vec::new(),
journal_days: 7,
journal_max: 20,
context_groups: vec![

View file

@ -91,10 +91,10 @@ pub fn memory_links(store: &Store, _provenance: &str, key: &str) -> Result<Vec<L
Ok(links)
}
pub fn memory_link_set(store: &Store, _provenance: &str, source: &str, target: &str, strength: f32) -> Result<String> {
pub fn memory_link_set(store: &Store, provenance: &str, source: &str, target: &str, strength: f32) -> Result<String> {
let s = store.resolve_key(source).map_err(|e| anyhow::anyhow!("{}", e))?;
let t = store.resolve_key(target).map_err(|e| anyhow::anyhow!("{}", e))?;
let old = store.set_link_strength(&s, &t, strength).map_err(|e| anyhow::anyhow!("{}", e))?;
let old = store.set_link_strength(&s, &t, strength, provenance).map_err(|e| anyhow::anyhow!("{}", e))?;
store.save().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(format!("{}{} strength {:.2}{:.2}", s, t, old, strength))
}
@ -107,13 +107,19 @@ pub fn memory_link_add(store: &Store, provenance: &str, source: &str, target: &s
Ok(format!("linked {}{} (strength={:.2})", s, t, strength))
}
pub fn memory_delete(store: &Store, _provenance: &str, key: &str) -> Result<String> {
pub fn memory_delete(store: &Store, provenance: &str, key: &str) -> Result<String> {
let resolved = store.resolve_key(key).map_err(|e| anyhow::anyhow!("{}", e))?;
store.delete_node(&resolved).map_err(|e| anyhow::anyhow!("{}", e))?;
store.delete_node(&resolved, provenance).map_err(|e| anyhow::anyhow!("{}", e))?;
store.save().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(format!("deleted {}", resolved))
}
pub fn memory_restore(store: &Store, provenance: &str, key: &str) -> Result<String> {
let result = store.restore_node(key, provenance).map_err(|e| anyhow::anyhow!("{}", e))?;
store.save().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(result)
}
pub fn memory_history(store: &Store, _provenance: &str, key: &str, full: Option<bool>) -> Result<String> {
let key = store.resolve_key(key).unwrap_or_else(|_| key.to_string());
let full = full.unwrap_or(false);
@ -171,9 +177,9 @@ pub fn memory_weight_set(store: &Store, _provenance: &str, key: &str, weight: f3
Ok(format!("weight {} {:.2}{:.2}", resolved, old, new))
}
pub fn memory_rename(store: &Store, _provenance: &str, old_key: &str, new_key: &str) -> Result<String> {
pub fn memory_rename(store: &Store, provenance: &str, old_key: &str, new_key: &str) -> Result<String> {
let resolved = store.resolve_key(old_key).map_err(|e| anyhow::anyhow!("{}", e))?;
store.rename_node(&resolved, new_key).map_err(|e| anyhow::anyhow!("{}", e))?;
store.rename_node(&resolved, new_key, provenance).map_err(|e| anyhow::anyhow!("{}", e))?;
store.save().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(format!("Renamed '{}' → '{}'", resolved, new_key))
}
@ -184,13 +190,43 @@ pub fn memory_supersede(store: &Store, provenance: &str, old_key: &str, new_key:
.map_err(|e| anyhow::anyhow!("{}", e))?
.map(|n| n.content)
.ok_or_else(|| anyhow::anyhow!("node not found: {}", old_key))?;
// Transfer links from old node to new node (if new_key exists)
let mut links_transferred = 0;
if store.contains_key(new_key).unwrap_or(false) {
// Get old node's neighbors
let old_neighbors = store.neighbors(old_key).unwrap_or_default();
// Get new node's existing neighbors (to avoid weakening existing links)
let new_neighbors: std::collections::HashMap<String, f32> = store.neighbors(new_key)
.unwrap_or_default()
.into_iter()
.collect();
for (neighbor_key, old_strength) in old_neighbors {
// Skip self-links
if neighbor_key == new_key { continue; }
// Only add/strengthen link if new node doesn't have a stronger one
let current = new_neighbors.get(&neighbor_key).copied().unwrap_or(0.0);
if old_strength > current {
if store.set_link_strength(new_key, &neighbor_key, old_strength, provenance).is_ok() {
links_transferred += 1;
}
}
}
}
let notice = format!("**SUPERSEDED** by `{}` — {}\n\n---\n\n{}",
new_key, reason, content.trim());
store.upsert_provenance(old_key, &notice, provenance)
.map_err(|e| anyhow::anyhow!("{}", e))?;
store.set_weight(old_key, 0.01).map_err(|e| anyhow::anyhow!("{}", e))?;
store.save().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(format!("superseded {}{} ({})", old_key, new_key, reason))
if links_transferred > 0 {
Ok(format!("superseded {}{} ({}), transferred {} links", old_key, new_key, reason, links_transferred))
} else {
Ok(format!("superseded {}{} ({})", old_key, new_key, reason))
}
}
/// Convert a list of keys to ReplayItems with priority and graph metrics.
@ -396,7 +432,7 @@ pub fn graph_communities(store: &Store, _provenance: &str, top_n: Option<usize>,
Ok(out)
}
pub fn graph_normalize_strengths(store: &Store, _provenance: &str, apply: Option<bool>) -> Result<String> {
pub fn graph_normalize_strengths(store: &Store, provenance: &str, apply: Option<bool>) -> Result<String> {
use crate::store::{StoreView, RelationType};
let apply = apply.unwrap_or(false);
@ -459,7 +495,7 @@ pub fn graph_normalize_strengths(store: &Store, _provenance: &str, apply: Option
if apply {
for (source, target, new_strength) in to_update {
store.set_link_strength(&source, &target, new_strength)?;
store.set_link_strength(&source, &target, new_strength, provenance)?;
}
writeln!(out, "\nApplied {} strength updates.", changed).ok();
} else {

View file

@ -7,11 +7,12 @@ use super::{index, types::*, Store};
use anyhow::{anyhow, bail, Result};
use std::collections::{HashMap, HashSet};
/// Fallback provenance for non-tool-dispatch paths (CLI, digest, etc.).
/// Tool dispatch passes provenance directly through thought::dispatch.
pub fn current_provenance() -> String {
std::env::var("POC_PROVENANCE")
.unwrap_or_else(|_| "manual".to_string())
/// Check if a key is protected from deletion/rename.
/// Uses protected_nodes list from config.
pub fn is_protected(key: &str) -> bool {
let config = crate::config::get();
config.protected_nodes.iter().any(|k| k == key)
}
impl Store {
@ -51,15 +52,13 @@ impl Store {
/// Upsert a node: update if exists (and content changed), create if not.
/// Returns: "created", "updated", or "unchanged".
///
/// Provenance is determined by the POC_PROVENANCE env var if set,
/// otherwise defaults to Manual.
/// Uses "manual" as the provenance (for CLI operations).
pub fn upsert(&self, key: &str, content: &str) -> Result<&'static str> {
let prov = current_provenance();
self.upsert_provenance(key, content, &prov)
self.upsert_provenance(key, content, "manual")
}
/// Upsert with explicit provenance (for agent-created nodes).
/// Updates to protected nodes are blocked.
pub fn upsert_provenance(&self, key: &str, content: &str, provenance: &str) -> Result<&'static str> {
let db = self.db.as_ref().ok_or_else(|| anyhow!("store not loaded"))?;
@ -67,6 +66,9 @@ impl Store {
if existing.content == content {
return Ok("unchanged");
}
if is_protected(key) {
bail!("Cannot modify protected node '{}' (in config protected_nodes)", key);
}
let mut node = existing;
node.content = content.to_string();
node.provenance = provenance.to_string();
@ -78,7 +80,18 @@ impl Store {
txn.commit()?;
Ok("updated")
} else {
let mut node = new_node(key, content);
// Check if there's a previous (possibly deleted) version to continue from
let mut node = if let Some(prev) = self.find_latest_by_key(key)? {
// Continue from previous version (maintains UUID and version continuity)
let mut n = prev;
n.content = content.to_string();
n.deleted = false;
n.timestamp = now_epoch();
n.version += 1;
n
} else {
new_node(key, content)
};
node.provenance = provenance.to_string();
let txn = db.begin_write()?;
let offset = self.append_nodes(std::slice::from_ref(&node))?;
@ -89,8 +102,11 @@ impl Store {
}
/// Soft-delete a node (appends deleted version, removes from index).
pub fn delete_node(&self, key: &str) -> Result<()> {
let prov = current_provenance();
/// Fails if node is in protected_nodes list.
pub fn delete_node(&self, key: &str, provenance: &str) -> Result<()> {
if is_protected(key) {
bail!("Cannot delete protected node '{}' (in config protected_nodes)", key);
}
let db = self.db.as_ref().ok_or_else(|| anyhow!("store not loaded"))?;
let node = self.get_node(key)?
@ -98,7 +114,7 @@ impl Store {
let mut deleted = node;
deleted.deleted = true;
deleted.version += 1;
deleted.provenance = prov;
deleted.provenance = provenance.to_string();
deleted.timestamp = now_epoch();
let txn = db.begin_write()?;
@ -108,15 +124,52 @@ impl Store {
Ok(())
}
/// Restore a deleted node to its last non-deleted state.
/// Returns the restored node's content preview.
pub fn restore_node(&self, key: &str, provenance: &str) -> Result<String> {
let db = self.db.as_ref().ok_or_else(|| anyhow!("store not loaded"))?;
// Check if node already exists (not deleted)
if self.contains_key(key)? {
bail!("Node '{}' is not deleted", key);
}
// Find the last non-deleted version (for content)
let last_live = self.find_last_live_version(key)?
.ok_or_else(|| anyhow!("No previous version of '{}' found", key))?;
// Find the absolute latest version (for version number continuity)
let latest = self.find_latest_by_key(key)?
.ok_or_else(|| anyhow!("No previous version of '{}' found", key))?;
// Create restored version: content from last_live, version from latest + 1
let mut restored = last_live.clone();
restored.deleted = false;
restored.version = latest.version + 1;
restored.timestamp = now_epoch();
restored.provenance = provenance.to_string();
let txn = db.begin_write()?;
let offset = self.append_nodes(std::slice::from_ref(&restored))?;
index::index_node(&txn, &restored.key, offset, &restored.uuid, restored.node_type as u8, restored.timestamp, &restored.provenance)?;
txn.commit()?;
let preview: String = restored.content.chars().take(100).collect();
Ok(format!("Restored '{}' (v{}): {}...", key, restored.version, preview))
}
/// Rename a node: change its key, update debug strings on all edges.
///
/// Graph edges (source/target UUIDs) are unaffected — they're already
/// UUID-based. We update the human-readable source_key/target_key strings
/// on relations, and created_at is preserved untouched.
pub fn rename_node(&self, old_key: &str, new_key: &str) -> Result<()> {
pub fn rename_node(&self, old_key: &str, new_key: &str, provenance: &str) -> Result<()> {
if old_key == new_key {
return Ok(());
}
if is_protected(old_key) {
bail!("Cannot rename protected node '{}' (in config protected_nodes)", old_key);
}
if self.contains_key(new_key)? {
bail!("Key '{}' already exists", new_key);
}
@ -124,20 +177,18 @@ impl Store {
let node = self.get_node(old_key)?
.ok_or_else(|| anyhow!("No node '{}'", old_key))?;
let prov = current_provenance();
// New version under the new key
let mut renamed = node.clone();
renamed.key = new_key.to_string();
renamed.version += 1;
renamed.provenance = prov.clone();
renamed.provenance = provenance.to_string();
renamed.timestamp = now_epoch();
// Deletion record for the old key (same UUID, independent version counter)
let mut tombstone = node.clone();
tombstone.deleted = true;
tombstone.version += 1;
tombstone.provenance = prov;
tombstone.provenance = provenance.to_string();
tombstone.timestamp = now_epoch();
// Find relations touching this node's UUID (read before txn)
@ -164,7 +215,7 @@ impl Store {
};
let mut rel = new_relation(src_uuid, tgt_uuid,
RelationType::from_u8(rel_type), strength,
&src_key, &tgt_key);
&src_key, &tgt_key, provenance);
rel.version = 2; // indicate update
updated_rels.push(rel);
}
@ -278,7 +329,7 @@ impl Store {
index::remove_relation(&txn, &source_uuid, &target_uuid, strength, rel_type)?;
let mut rel = new_relation(source_uuid, target_uuid,
RelationType::from_u8(rel_type), strength,
&source_key, &target_key);
&source_key, &target_key, "system");
rel.deleted = true;
rel.version = 2;
self.append_relations(std::slice::from_ref(&rel))?;
@ -311,7 +362,7 @@ impl Store {
/// Set the strength of a link between two nodes.
/// Returns the old strength. Creates link if it doesn't exist.
pub fn set_link_strength(&self, source: &str, target: &str, strength: f32) -> Result<f32> {
pub fn set_link_strength(&self, source: &str, target: &str, strength: f32, provenance: &str) -> Result<f32> {
let strength = strength.clamp(0.01, 1.0);
let source_uuid = self.get_node(source)?
@ -337,14 +388,14 @@ impl Store {
index::index_relation(&txn, &source_uuid, &target_uuid, strength, rel_type)?;
// Append updated relation to log
let mut rel = new_relation(source_uuid, target_uuid,
RelationType::from_u8(rel_type), strength, source, target);
RelationType::from_u8(rel_type), strength, source, target, provenance);
rel.version = 2; // indicate update
self.append_relations(std::slice::from_ref(&rel))?;
txn.commit()?;
Ok(old_strength)
} else {
// Create new link then update its strength
self.add_link(source, target, "link_set")?;
self.add_link(source, target, provenance)?;
let db = self.db.as_ref().ok_or_else(|| anyhow!("store not loaded"))?;
let txn = db.begin_write()?;
index::remove_relation(&txn, &source_uuid, &target_uuid, 0.1, RelationType::Link as u8)?;
@ -377,12 +428,11 @@ impl Store {
let jaccard = graph.jaccard(source, target);
let strength = (jaccard * 3.0).clamp(0.1, 1.0) as f32;
let mut rel = new_relation(
let rel = new_relation(
source_uuid, target_uuid,
RelationType::Link, strength,
source, target,
source, target, provenance,
);
rel.provenance = provenance.to_string();
self.add_relation(rel)?;
Ok(strength)
}