Skip to main content

agentstatedeveloper_core/
ledger.rs

1use std::collections::HashSet;
2
3use agentstategraph::{CommitOptions, Repository};
4use agentstategraph_core::IntentCategory;
5
6use crate::engine::Engine;
7use crate::error::{AsdError, Result};
8use crate::paths;
9use crate::schema::LedgerEntry;
10use crate::search_fts::SearchFtsDb;
11
12// ---------------------------------------------------------------------------
13// RatifyOps — narrower trait for the approve/reject/withdraw operations.
14// Lives in core so Engine can hold an Arc<dyn RatifyOps> without depending
15// on the commercial ratify crate. agentstatedeveloper-ratify implements this.
16// ---------------------------------------------------------------------------
17
18/// The three ledger review operations. Implemented by `RatifyOpsImpl`
19/// in `agentstatedeveloper-ratify` (Team tier). Methods take an explicit
20/// `repo: &Repository` so the impl can be a zero-sized struct — no lifetime
21/// or clone needed when stored as `Arc<dyn RatifyOps>` in `Engine`.
22pub trait RatifyOps: Send + Sync {
23    fn approve_entry(
24        &self,
25        repo: &Repository,
26        ref_name: &str,
27        entry_id: &str,
28        approver_id: &str,
29        approver_kind: &str,
30        message: Option<&str>,
31        agent_id: &str,
32    ) -> Result<ApprovalOutcome>;
33
34    fn reject_entry(
35        &self,
36        repo: &Repository,
37        ref_name: &str,
38        entry_id: &str,
39        reviewer_id: &str,
40        reviewer_kind: &str,
41        reason: &str,
42        agent_id: &str,
43    ) -> Result<ReviewOutcome>;
44
45    fn withdraw_entry(
46        &self,
47        repo: &Repository,
48        ref_name: &str,
49        entry_id: &str,
50        author_id: &str,
51        agent_id: &str,
52    ) -> Result<ReviewOutcome>;
53}
54
55/// Outcome of [`LedgerStore::approve_entry`]. Carries the updated entry
56/// and whether it was already approved (caller idempotency hint).
57#[derive(Debug, Clone)]
58pub struct ApprovalOutcome {
59    pub entry: LedgerEntry,
60    pub already_approved: bool,
61}
62
63/// Outcome of [`LedgerStore::reject_entry`] / `withdraw_entry`. Same
64/// idempotency shape as [`ApprovalOutcome`].
65#[derive(Debug, Clone)]
66pub struct ReviewOutcome {
67    pub entry: LedgerEntry,
68    pub already_resolved: bool,
69}
70
71pub trait LedgerStore {
72    fn append_entry(&self, ref_name: &str, entry: &LedgerEntry, agent_id: &str) -> Result<()>;
73
74    /// List entries for a symbol, newest first. By default, entries that
75    /// have been superseded by a later entry are filtered out.
76    fn list_entries(&self, ref_name: &str, symbol_id: &str) -> Result<Vec<LedgerEntry>> {
77        let all = self.list_entries_with_superseded(ref_name, symbol_id)?;
78        let superseded: HashSet<String> = all
79            .iter()
80            .flat_map(|e| e.supersedes.iter().cloned())
81            .collect();
82        Ok(all
83            .into_iter()
84            .filter(|e| !superseded.contains(&e.entry_id))
85            .collect())
86    }
87
88    /// Full list including superseded entries.
89    fn list_entries_with_superseded(
90        &self,
91        ref_name: &str,
92        symbol_id: &str,
93    ) -> Result<Vec<LedgerEntry>>;
94
95    /// Approve an awaiting-approval entry. The OSS default returns a
96    /// "commercial feature" error — the real implementation lives in
97    /// the `agentstatedeveloper-ratify` crate (Team-tier).
98    fn approve_entry(
99        &self,
100        _ref_name: &str,
101        _entry_id: &str,
102        _approver_id: &str,
103        _approver_kind: &str,
104        _message: Option<&str>,
105        _agent_id: &str,
106    ) -> Result<ApprovalOutcome> {
107        Err(AsdError::Other(
108            "ledger approve is a commercial feature (Team tier) — \
109             install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
110                .into(),
111        ))
112    }
113
114    /// Reject an awaiting-approval entry. See `approve_entry` — OSS
115    /// default errors; real impl in `agentstatedeveloper-ratify`.
116    fn reject_entry(
117        &self,
118        _ref_name: &str,
119        _entry_id: &str,
120        _reviewer_id: &str,
121        _reviewer_kind: &str,
122        _reason: &str,
123        _agent_id: &str,
124    ) -> Result<ReviewOutcome> {
125        Err(AsdError::Other(
126            "ledger reject is a commercial feature (Team tier) — \
127             install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
128                .into(),
129        ))
130    }
131
132    /// Withdraw an awaiting-approval entry. See `approve_entry` — OSS
133    /// default errors; real impl in `agentstatedeveloper-ratify`.
134    fn withdraw_entry(
135        &self,
136        _ref_name: &str,
137        _entry_id: &str,
138        _author_id: &str,
139        _agent_id: &str,
140    ) -> Result<ReviewOutcome> {
141        Err(AsdError::Other(
142            "ledger withdraw is a commercial feature (Team tier) — \
143             install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
144                .into(),
145        ))
146    }
147}
148
149pub struct AsgLedgerStore<'a> {
150    pub repo: &'a Repository,
151    /// Borrowed FTS connection from the owning `Engine`.  When `Some`,
152    /// enables the SQLite write-through cache: `list_entries` reads from
153    /// SQLite when populated, `append_entry` writes to SQLite after the git
154    /// commit.  No `Connection::open` on every method call.
155    pub fts: Option<&'a SearchFtsDb>,
156}
157
158impl<'a> AsgLedgerStore<'a> {
159    /// Construct without SQLite caching (tests, internal engine calls).
160    pub fn new(repo: &'a Repository) -> Self {
161        Self { repo, fts: None }
162    }
163    /// Convenience: borrow the FTS connection already open in `engine`.
164    pub fn from_engine(engine: &'a Engine) -> Self {
165        Self {
166            repo: &engine.repo,
167            fts: engine.fts.as_ref(),
168        }
169    }
170}
171
172impl<'a> LedgerStore for AsgLedgerStore<'a> {
173    fn append_entry(&self, ref_name: &str, entry: &LedgerEntry, agent_id: &str) -> Result<()> {
174        let path = paths::ledger_entry_path(&entry.symbol_id, &entry.entry_id);
175        let value = serde_json::to_value(entry)?;
176        let mut opts = CommitOptions::new(
177            agent_id,
178            IntentCategory::Refine,
179            format!("ledger {} for {}", entry.kind.as_str(), entry.symbol_id),
180        );
181        // Carry the decision's own rationale and confidence onto the commit as
182        // provenance (Plan C t-001). Prefer the detailed body; fall back to the
183        // summary so `reasoning` is never emptier than what the entry records.
184        let reasoning = entry.body.clone().unwrap_or_else(|| entry.summary.clone());
185        if !reasoning.is_empty() {
186            opts = opts.with_reasoning(reasoning);
187        }
188        if let Some(confidence) = entry.confidence {
189            opts = opts.with_confidence(confidence);
190        }
191        self.repo.set_json(ref_name, &path, &value, opts)?;
192
193        // Write reverse index: entry_id → symbol_id for O(1) find_entry.
194        let idx_path = paths::ledger_entry_index_path(&entry.entry_id);
195        let idx_val = serde_json::Value::String(entry.symbol_id.clone());
196        let idx_opts = CommitOptions::new(
197            agent_id,
198            IntentCategory::Refine,
199            format!("ledger-idx {}", entry.entry_id),
200        );
201        self.repo
202            .set_json(ref_name, &idx_path, &idx_val, idx_opts)?;
203
204        // Best-effort SQLite write-through; failures are non-fatal.
205        if let Some(fts) = self.fts {
206            let _ = fts.upsert_ledger_entry(entry, ref_name);
207        }
208        Ok(())
209    }
210
211    fn list_entries_with_superseded(
212        &self,
213        ref_name: &str,
214        symbol_id: &str,
215    ) -> Result<Vec<LedgerEntry>> {
216        // Fast path: SQLite cache — zero git tree walks when populated.
217        if let Some(fts) = self.fts {
218            if fts.ledger_entry_count_for(symbol_id, ref_name) > 0 {
219                if let Ok(entries) = fts.list_ledger_entries_for(symbol_id, ref_name) {
220                    return Ok(entries);
221                }
222            }
223        }
224
225        // Authoritative git path — also runs when SQLite is empty (first run
226        // after `git pull`).  Populates the cache as a side effect.
227        let parent = paths::ledger_symbol_path(symbol_id);
228        let json = match self.repo.get_json(ref_name, &parent) {
229            Ok(v) => v,
230            Err(_) => return Ok(Vec::new()),
231        };
232        let mut entries = Vec::new();
233        if let serde_json::Value::Object(map) = json {
234            for (k, v) in map {
235                match serde_json::from_value::<LedgerEntry>(v) {
236                    Ok(e) => entries.push(e),
237                    Err(err) => eprintln!(
238                        "warning: skipping malformed ledger entry {}/{}: {}",
239                        symbol_id, k, err
240                    ),
241                }
242            }
243        }
244        entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
245
246        // Populate cache for next call — best effort.
247        if !entries.is_empty() {
248            if let Some(fts) = self.fts {
249                for entry in &entries {
250                    let _ = fts.upsert_ledger_entry(entry, ref_name);
251                }
252            }
253        }
254
255        Ok(entries)
256    }
257}
258
259// ---------------------------------------------------------------------------
260// Orphan detection
261// ---------------------------------------------------------------------------
262
263/// Walk every ledger entry and tag those whose `symbol_id` is no longer
264/// present in the qname index as `"orphaned"` and `"orphaned-at:<timestamp>"`;
265/// clear those tags from entries whose symbol is back.
266///
267/// Returns the number of entries newly tagged. Already-orphaned entries
268/// (already carrying the `"orphaned"` tag) are skipped. Runs in O(symbols +
269/// ledger_entries) — safe to call at the end of `asd index` or from `health`.
270pub fn detect_orphaned_entries(repo: &Repository, ref_name: &str, agent_id: &str) -> Result<usize> {
271    tag_orphaned_entries(repo, ref_name, agent_id).map(|changed| changed.tagged.len())
272}
273
274/// The entries one orphan-tagging pass changed.
275#[derive(Default)]
276pub(crate) struct OrphanTags {
277    /// Newly tagged: their symbol left the index.
278    pub tagged: Vec<LedgerEntry>,
279    /// Tags cleared: their symbol is back — a branch switched back to, a
280    /// file restored.
281    pub untagged: Vec<LedgerEntry>,
282}
283
284fn is_orphan_tag(tag: &str) -> bool {
285    tag == "orphaned" || tag.starts_with("orphaned-at:")
286}
287
288/// [`detect_orphaned_entries`], returning the entries it changed so the
289/// caller can bring the ledger cache up to date.
290///
291/// An entry whose symbol is back loses its orphan tags and gains
292/// `reattached-at:<now>`. Conclusions import keeps whichever copy of an entry
293/// was revised last, judged by its `*-at:` tags, so without a later tag a
294/// copy exported while the entry was tagged would count as newer and tag it
295/// again.
296///
297/// Every change lands in one commit. A commit per entry stored a fresh copy
298/// of the ledger map each time.
299pub(crate) fn tag_orphaned_entries(
300    repo: &Repository,
301    ref_name: &str,
302    agent_id: &str,
303) -> Result<OrphanTags> {
304    use chrono::Utc;
305
306    let (spec, fork) = crate::subtree::speculate_at_head(repo, ref_name, "asd-tag-orphans")?;
307    let staged = (|| -> Result<OrphanTags> {
308        // Every symbol_id currently in the index.
309        let indexed: HashSet<String> =
310            crate::subtree::read_seed(repo, &fork, &format!("{}/index/by-qname", paths::ASD_ROOT))?
311                .values()
312                .filter_map(|v| v.get("symbol_id")?.as_str().map(|s| s.to_string()))
313                .collect();
314        let mut ledger = crate::subtree::read_seed(repo, &fork, &paths::ledger_root())?;
315
316        let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
317        let orphaned_at = format!("orphaned-at:{now}");
318        let reattached_at = format!("reattached-at:{now}");
319        let mut changed = OrphanTags::default();
320        for (sym_id, per_symbol) in ledger.iter_mut() {
321            let live = indexed.contains(sym_id);
322            let serde_json::Value::Object(entries_map) = per_symbol else {
323                continue;
324            };
325            for value in entries_map.values_mut() {
326                // Read the tags off the stored value: parsing every entry in
327                // a 12,000-entry ledger on each index is wasted work.
328                let tagged = value
329                    .get("tags")
330                    .and_then(serde_json::Value::as_array)
331                    .is_some_and(|tags| {
332                        tags.iter()
333                            .filter_map(serde_json::Value::as_str)
334                            .any(is_orphan_tag)
335                    });
336                if live != tagged {
337                    continue;
338                }
339                let Ok(mut entry) = serde_json::from_value::<LedgerEntry>(value.clone()) else {
340                    continue;
341                };
342                if live {
343                    entry
344                        .tags
345                        .retain(|t| !is_orphan_tag(t) && !t.starts_with("reattached-at:"));
346                    entry.tags.push(reattached_at.clone());
347                } else {
348                    entry.tags.push("orphaned".to_string());
349                    entry.tags.push(orphaned_at.clone());
350                }
351                *value = serde_json::to_value(&entry)?;
352                if live {
353                    changed.untagged.push(entry);
354                } else {
355                    changed.tagged.push(entry);
356                }
357            }
358        }
359        if !changed.tagged.is_empty() || !changed.untagged.is_empty() {
360            repo.spec_set_json(
361                spec,
362                &paths::ledger_root(),
363                &serde_json::Value::Object(ledger),
364            )
365            .map_err(|e| AsdError::Other(e.to_string()))?;
366        }
367        Ok(changed)
368    })();
369    match staged {
370        Ok(changed) if changed.tagged.is_empty() && changed.untagged.is_empty() => {
371            let _ = repo.discard_speculation(spec);
372            Ok(changed)
373        }
374        Ok(changed) => {
375            let plural = |n: usize| if n == 1 { "y" } else { "ies" };
376            let (t, u) = (changed.tagged.len(), changed.untagged.len());
377            let opts = CommitOptions::new(
378                agent_id,
379                IntentCategory::Refine,
380                match (t, u) {
381                    (_, 0) => format!("tag {t} orphaned ledger entr{}", plural(t)),
382                    (0, _) => format!("clear orphan tags from {u} ledger entr{}", plural(u)),
383                    _ => format!(
384                        "tag {t} orphaned ledger entr{}, clear orphan tags from {u}",
385                        plural(t)
386                    ),
387                },
388            )
389            .with_reasoning(
390                changed
391                    .tagged
392                    .iter()
393                    .map(|e| format!("tagged {}", e.entry_id))
394                    .chain(
395                        changed
396                            .untagged
397                            .iter()
398                            .map(|e| format!("cleared {}", e.entry_id)),
399                    )
400                    .collect::<Vec<_>>()
401                    .join("\n"),
402            );
403            repo.commit_speculation(spec, opts)
404                .map_err(|e| AsdError::Other(e.to_string()))?;
405            Ok(changed)
406        }
407        Err(e) => {
408            let _ = repo.discard_speculation(spec);
409            Err(e)
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    struct NopStore;
419
420    impl LedgerStore for NopStore {
421        fn append_entry(&self, _: &str, _: &LedgerEntry, _: &str) -> crate::error::Result<()> {
422            Ok(())
423        }
424        fn list_entries_with_superseded(
425            &self,
426            _: &str,
427            _: &str,
428        ) -> crate::error::Result<Vec<LedgerEntry>> {
429            Ok(vec![])
430        }
431    }
432
433    #[test]
434    fn oss_approve_returns_commercial_error_with_url() {
435        let store = NopStore;
436        let err = store
437            .approve_entry("main", "e1", "alice", "human", None, "agent")
438            .unwrap_err();
439        let msg = err.to_string();
440        assert!(msg.contains("commercial feature"), "msg: {}", msg);
441        assert!(
442            msg.contains("agentstatedeveloper.dev/pricing"),
443            "msg: {}",
444            msg
445        );
446    }
447
448    #[test]
449    fn oss_reject_returns_commercial_error_with_url() {
450        let store = NopStore;
451        let err = store
452            .reject_entry("main", "e1", "alice", "human", "reason", "agent")
453            .unwrap_err();
454        let msg = err.to_string();
455        assert!(msg.contains("commercial feature"), "msg: {}", msg);
456        assert!(
457            msg.contains("agentstatedeveloper.dev/pricing"),
458            "msg: {}",
459            msg
460        );
461    }
462
463    #[test]
464    fn oss_withdraw_returns_commercial_error_with_url() {
465        let store = NopStore;
466        let err = store
467            .withdraw_entry("main", "e1", "alice", "agent")
468            .unwrap_err();
469        let msg = err.to_string();
470        assert!(msg.contains("commercial feature"), "msg: {}", msg);
471        assert!(
472            msg.contains("agentstatedeveloper.dev/pricing"),
473            "msg: {}",
474            msg
475        );
476    }
477}