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:
parent
cc29cd2225
commit
6ec7fcb777
4 changed files with 159 additions and 38 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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, ¬ice, 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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue