Skip to main content

agentstatedeveloper_core/
rebind.rs

1//! Moving a symbol's ledger history onto another symbol.
2//!
3//! A rebind is a move: every entry filed under the old symbol id leaves it
4//! and lands under the new one, the entry index and the SQLite ledger cache
5//! follow, and a record under `/asd/v1/rebinds/<old id>` says where it went.
6//! The CLI and MCP used to copy instead — each entry ended up under both ids,
7//! the index and cache kept pointing at the old one, and an orphaned entry
8//! stayed orphaned — with a commit per entry.
9//!
10//! Any number of rebinds land in one commit, written on top of the state the
11//! speculation forked from (see [`crate::subtree`]).
12
13use chrono::Utc;
14use serde::Serialize;
15use serde_json::Value;
16
17use agentstategraph::{CommitOptions, Repository};
18use agentstategraph_core::IntentCategory;
19
20use crate::error::{AsdError, Result};
21use crate::paths;
22use crate::schema::{LedgerEntry, Rebind, Symbol};
23use crate::search_fts::SearchFtsDb;
24
25/// What one rebind moved.
26#[derive(Debug, Clone, Serialize)]
27pub struct RebindOutcome {
28    pub from_symbol_id: String,
29    pub to_symbol_id: String,
30    pub to_qname: String,
31    pub entries_moved: usize,
32}
33
34/// Move the ledger entries filed under each `from` symbol id onto its target
35/// symbol, in one commit.
36///
37/// Each moved entry loses its `orphaned`/`orphaned-at:` tags and gains
38/// `rebound-at:<now>`. The new tag matters beyond the record: conclusions
39/// import keeps whichever copy of an entry was revised last, judged by its
40/// `*-at:` tags. An entry tagged orphaned whose symbol later came back is
41/// exported with `orphaned-at:`, and without a later tag that copy would
42/// count as newer and put the entry back where it came from.
43///
44/// Rebinding from a symbol with no entries still records the rebind (a
45/// rename noted for later). A `from` id listed twice, or equal to its
46/// target, is an error, and nothing is written.
47pub fn rebind_ledger(
48    repo: &Repository,
49    fts: Option<&SearchFtsDb>,
50    ref_name: &str,
51    rebinds: &[(String, Symbol)],
52    agent_id: &str,
53) -> Result<Vec<RebindOutcome>> {
54    let mut seen = std::collections::HashSet::new();
55    for (from, to) in rebinds {
56        if *from == to.symbol_id {
57            return Err(AsdError::Other(format!(
58                "{from} and {} are the same symbol — nothing to rebind",
59                to.qname
60            )));
61        }
62        if !seen.insert(from.as_str()) {
63            return Err(AsdError::Other(format!("{from} is listed twice")));
64        }
65    }
66    if rebinds.is_empty() {
67        return Ok(Vec::new());
68    }
69
70    let (spec, fork) = crate::subtree::speculate_at_head(repo, ref_name, "asd-rebind")?;
71    let staged = (|| -> Result<(Vec<RebindOutcome>, Vec<LedgerEntry>)> {
72        let rebinds_root = format!("{}/rebinds", paths::ASD_ROOT);
73        let mut ledger = crate::subtree::read_seed(repo, &fork, &paths::ledger_root())?;
74        let mut index = crate::subtree::read_seed(repo, &fork, &paths::ledger_index_root())?;
75        let mut records = crate::subtree::read_seed(repo, &fork, &rebinds_root)?;
76        let now = Utc::now();
77        let rebound_at = format!("rebound-at:{}", now.format("%Y-%m-%dT%H:%M:%SZ"));
78
79        let mut outcomes = Vec::with_capacity(rebinds.len());
80        let mut moved = Vec::new();
81        for (from, to) in rebinds {
82            let entries = match ledger.remove(from) {
83                Some(Value::Object(entries)) => entries,
84                _ => Default::default(),
85            };
86            let entries_moved = entries.len();
87            if entries_moved > 0 {
88                let Value::Object(dest) = ledger
89                    .entry(to.symbol_id.clone())
90                    .or_insert_with(|| Value::Object(Default::default()))
91                else {
92                    return Err(AsdError::Other(format!(
93                        "ledger node for {} is not a map",
94                        to.symbol_id
95                    )));
96                };
97                for (entry_id, value) in entries {
98                    let mut entry: LedgerEntry = serde_json::from_value(value).map_err(|e| {
99                        AsdError::Other(format!("ledger entry {entry_id} under {from}: {e}"))
100                    })?;
101                    entry.symbol_id = to.symbol_id.clone();
102                    entry
103                        .tags
104                        .retain(|t| t != "orphaned" && !t.starts_with("orphaned-at:"));
105                    entry.tags.push(rebound_at.clone());
106                    index.insert(entry_id.clone(), Value::String(to.symbol_id.clone()));
107                    dest.insert(entry_id, serde_json::to_value(&entry)?);
108                    moved.push(entry);
109                }
110            }
111            let record = Rebind {
112                from_symbol_id: from.clone(),
113                to_symbol_id: to.symbol_id.clone(),
114                to_qname: to.qname.clone(),
115                at: now,
116                by: agent_id.to_string(),
117            };
118            records.insert(from.clone(), serde_json::to_value(&record)?);
119            outcomes.push(RebindOutcome {
120                from_symbol_id: from.clone(),
121                to_symbol_id: to.symbol_id.clone(),
122                to_qname: to.qname.clone(),
123                entries_moved,
124            });
125        }
126
127        let write = |path: &str, map: serde_json::Map<String, Value>| {
128            repo.spec_set_json(spec, path, &Value::Object(map))
129                .map_err(|e| AsdError::Other(e.to_string()))
130        };
131        if !moved.is_empty() {
132            write(&paths::ledger_root(), ledger)?;
133            write(&paths::ledger_index_root(), index)?;
134        }
135        write(&rebinds_root, records)?;
136        Ok((outcomes, moved))
137    })();
138    let (outcomes, moved) = match staged {
139        Ok(staged) => staged,
140        Err(e) => {
141            let _ = repo.discard_speculation(spec);
142            return Err(e);
143        }
144    };
145
146    let reasoning = outcomes
147        .iter()
148        .map(|o| {
149            format!(
150                "{} → {} ({}, {} entries)",
151                o.from_symbol_id, o.to_qname, o.to_symbol_id, o.entries_moved
152            )
153        })
154        .collect::<Vec<_>>()
155        .join("\n");
156    let opts = CommitOptions::new(
157        agent_id,
158        IntentCategory::Refine,
159        format!(
160            "rebind {} symbol(s), moving {} ledger entr{}",
161            outcomes.len(),
162            moved.len(),
163            if moved.len() == 1 { "y" } else { "ies" }
164        ),
165    )
166    .with_reasoning(reasoning);
167    repo.commit_speculation(spec, opts)
168        .map_err(|e| AsdError::Other(e.to_string()))?;
169
170    // The cache answers `list_entries`; a row left on the old id would keep
171    // showing the entry there.
172    if let Some(fts) = fts {
173        for entry in &moved {
174            fts.upsert_ledger_entry(entry, ref_name)
175                .map_err(|e| AsdError::Other(format!("ledger cache: {e}")))?;
176        }
177    }
178    Ok(outcomes)
179}