Skip to main content

edda_ledger/
ledger.rs

1use crate::paths::EddaPaths;
2use crate::sqlite_store::{BundleRow, SqliteStore};
3use crate::view::{self, DecisionView};
4use anyhow::Context;
5use edda_core::Event;
6use std::path::Path;
7
8/// The append-only event ledger (SQLite backend).
9pub struct Ledger {
10    pub paths: EddaPaths,
11    pub(crate) sqlite: SqliteStore,
12}
13
14impl Ledger {
15    /// Open an existing workspace. Fails if `.edda/` does not exist.
16    pub fn open(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<Self> {
17        let paths = EddaPaths::discover(repo_root);
18        if !paths.is_initialized() {
19            anyhow::bail!(
20                "not an edda workspace ({}/.edda not found). Run `edda init` first.",
21                paths.root.display()
22            );
23        }
24        let sqlite = SqliteStore::open_or_create(&paths.ledger_db)?;
25        Ok(Self { paths, sqlite })
26    }
27
28    /// Open a workspace, auto-initializing `.edda/` if missing.
29    ///
30    /// Use this for read-path consumers (e.g. `edda watch`) that should
31    /// work without requiring the user to run `edda init` first.
32    ///
33    /// This is a **lightweight init** — it only creates the ledger directory
34    /// layout and SQLite DB. Config files (`policy.yaml`, `actors.yaml`) and
35    /// bridge hooks are NOT created; those require `edda init`.
36    pub fn open_or_init(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<Self> {
37        let root = repo_root.into();
38        let paths = EddaPaths::discover(&root);
39        if !paths.is_initialized() {
40            init_workspace(&paths)?;
41            init_head(&paths, "main")?;
42            init_branches_json(&paths, "main")?;
43        }
44        Self::open(root)
45    }
46
47    /// Ensure `.edda/` and ledger exist, without returning a Ledger handle.
48    ///
49    /// Use this when you only need the side effect (workspace creation)
50    /// and will open the ledger separately later.
51    pub fn ensure_initialized(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<()> {
52        let root = repo_root.into();
53        let paths = EddaPaths::discover(&root);
54        if !paths.is_initialized() {
55            init_workspace(&paths)?;
56            init_head(&paths, "main")?;
57            init_branches_json(&paths, "main")?;
58        }
59        Ok(())
60    }
61
62    /// Convenience: open from a Path ref (avoids Into<PathBuf> ambiguity).
63    pub fn open_path(repo_root: &Path) -> anyhow::Result<Self> {
64        Self::open(repo_root.to_path_buf())
65    }
66
67    // ── HEAD branch ─────────────────────────────────────────────────
68
69    /// Read the current HEAD branch name.
70    pub fn head_branch(&self) -> anyhow::Result<String> {
71        self.sqlite.head_branch().context("Ledger::head_branch")
72    }
73
74    /// Write the HEAD branch name.
75    pub fn set_head_branch(&self, name: &str) -> anyhow::Result<()> {
76        self.sqlite
77            .set_head_branch(name)
78            .context("Ledger::set_head_branch")
79    }
80
81    // ── Events ──────────────────────────────────────────────────────
82
83    /// Append an event to the ledger. Append-only (CONTRACT LEDGER-02).
84    pub fn append_event(&self, event: &Event) -> anyhow::Result<()> {
85        self.sqlite
86            .append_event(event)
87            .with_context(|| format!("Ledger::append_event({})", event.event_id))
88    }
89
90    /// Append an event idempotently. Returns `true` if inserted, `false` if duplicate.
91    pub fn append_event_idempotent(&self, event: &Event) -> anyhow::Result<bool> {
92        self.sqlite
93            .append_event_idempotent(event)
94            .with_context(|| format!("Ledger::append_event_idempotent({})", event.event_id))
95    }
96
97    /// Get the hash of the last event, or `None` if the ledger is empty.
98    pub fn last_event_hash(&self) -> anyhow::Result<Option<String>> {
99        self.sqlite
100            .last_event_hash()
101            .context("Ledger::last_event_hash")
102    }
103
104    /// Read all events in the ledger.
105    pub fn iter_events(&self) -> anyhow::Result<Vec<Event>> {
106        self.sqlite.iter_events().context("Ledger::iter_events")
107    }
108
109    /// Get a single event by event_id.
110    pub fn get_event(&self, event_id: &str) -> anyhow::Result<Option<Event>> {
111        self.sqlite
112            .get_event(event_id)
113            .with_context(|| format!("Ledger::get_event({event_id})"))
114    }
115
116    /// Get all events of a given type, filtered at the SQL level.
117    pub fn iter_events_by_type(&self, event_type: &str) -> anyhow::Result<Vec<Event>> {
118        self.sqlite
119            .iter_events_by_type(event_type)
120            .with_context(|| format!("Ledger::iter_events_by_type({event_type})"))
121    }
122
123    /// Get all events for a specific branch, filtered at the SQL level.
124    pub fn iter_branch_events(&self, branch: &str) -> anyhow::Result<Vec<Event>> {
125        self.sqlite
126            .iter_branch_events(branch)
127            .with_context(|| format!("Ledger::iter_branch_events({branch})"))
128    }
129
130    /// Get events filtered by branch with optional type/keyword/date/limit,
131    /// all pushed down to SQL. Returns newest-first, capped at `limit`.
132    pub fn iter_events_filtered(
133        &self,
134        branch: &str,
135        event_type: Option<&str>,
136        keyword: Option<&str>,
137        after: Option<&str>,
138        before: Option<&str>,
139        limit: usize,
140    ) -> anyhow::Result<Vec<Event>> {
141        self.sqlite
142            .iter_events_filtered(branch, event_type, keyword, after, before, limit)
143            .with_context(|| format!("Ledger::iter_events_filtered(branch={branch})"))
144    }
145
146    /// Find commit events related to a query by evidence chain or keyword match.
147    pub fn find_related_commits(
148        &self,
149        branch: Option<&str>,
150        keyword: &str,
151        decision_event_ids: &[&str],
152        limit: usize,
153    ) -> anyhow::Result<Vec<Event>> {
154        self.sqlite
155            .find_related_commits(branch, keyword, decision_event_ids, limit)
156            .context("Ledger::find_related_commits")
157    }
158
159    /// Find note events matching a keyword, excluding decisions and session digests.
160    pub fn find_related_notes(
161        &self,
162        branch: Option<&str>,
163        keyword: &str,
164        limit: usize,
165    ) -> anyhow::Result<Vec<Event>> {
166        self.sqlite
167            .find_related_notes(branch, keyword, limit)
168            .context("Ledger::find_related_notes")
169    }
170
171    /// Get all events with rowid strictly greater than `after_rowid`.
172    ///
173    /// Returns `(rowid, Event)` pairs ordered by rowid, useful for cursor-based
174    /// polling (e.g. SSE streaming).
175    pub fn events_after_rowid(&self, after_rowid: i64) -> anyhow::Result<Vec<(i64, Event)>> {
176        self.sqlite
177            .events_after_rowid(after_rowid)
178            .context("Ledger::events_after_rowid")
179    }
180
181    /// Look up the rowid for a given `event_id`.
182    pub fn rowid_for_event_id(&self, event_id: &str) -> anyhow::Result<Option<i64>> {
183        self.sqlite
184            .rowid_for_event_id(event_id)
185            .with_context(|| format!("Ledger::rowid_for_event_id({event_id})"))
186    }
187
188    // ── Branches JSON ───────────────────────────────────────────────
189
190    /// Read branches.json content.
191    pub fn branches_json(&self) -> anyhow::Result<serde_json::Value> {
192        self.sqlite.branches_json().context("Ledger::branches_json")
193    }
194
195    /// Write branches.json content.
196    pub fn set_branches_json(&self, value: &serde_json::Value) -> anyhow::Result<()> {
197        self.sqlite
198            .set_branches_json(value)
199            .context("Ledger::set_branches_json")
200    }
201
202    // ── Decisions ───────────────────────────────────────────────────
203
204    /// Query active decisions, optionally filtered by domain or key pattern.
205    /// `after`/`before` are optional ISO 8601 bounds for temporal filtering.
206    pub fn active_decisions(
207        &self,
208        domain: Option<&str>,
209        key_pattern: Option<&str>,
210        after: Option<&str>,
211        before: Option<&str>,
212    ) -> anyhow::Result<Vec<DecisionView>> {
213        let rows = self
214            .sqlite
215            .active_decisions(domain, key_pattern, after, before, None)
216            .with_context(|| format!("Ledger::active_decisions(domain={domain:?})"))?;
217        Ok(rows.iter().map(view::to_view).collect())
218    }
219
220    /// Query active decisions with limit for hot path optimization.
221    pub fn active_decisions_limited(
222        &self,
223        domain: Option<&str>,
224        key_pattern: Option<&str>,
225        after: Option<&str>,
226        before: Option<&str>,
227        limit: usize,
228    ) -> anyhow::Result<Vec<DecisionView>> {
229        let rows = self
230            .sqlite
231            .active_decisions(domain, key_pattern, after, before, Some(limit))
232            .with_context(|| format!("Ledger::active_decisions_limited(domain={domain:?})"))?;
233        Ok(rows.iter().map(view::to_view).collect())
234    }
235
236    /// All decisions for a key (active + superseded), ordered by time.
237    /// `after`/`before` are optional ISO 8601 bounds for temporal filtering.
238    pub fn decision_timeline(
239        &self,
240        key: &str,
241        after: Option<&str>,
242        before: Option<&str>,
243    ) -> anyhow::Result<Vec<DecisionView>> {
244        let rows = self
245            .sqlite
246            .decision_timeline(key, after, before)
247            .with_context(|| format!("Ledger::decision_timeline(key={key})"))?;
248        Ok(rows.iter().map(view::to_view).collect())
249    }
250
251    /// All decisions for a domain (active + superseded), ordered by time.
252    /// `after`/`before` are optional ISO 8601 bounds for temporal filtering.
253    pub fn domain_timeline(
254        &self,
255        domain: &str,
256        after: Option<&str>,
257        before: Option<&str>,
258    ) -> anyhow::Result<Vec<DecisionView>> {
259        let rows = self
260            .sqlite
261            .domain_timeline(domain, after, before)
262            .with_context(|| format!("Ledger::domain_timeline(domain={domain})"))?;
263        Ok(rows.iter().map(view::to_view).collect())
264    }
265
266    /// Distinct domain values from active decisions.
267    pub fn list_domains(&self) -> anyhow::Result<Vec<String>> {
268        self.sqlite.list_domains().context("Ledger::list_domains")
269    }
270
271    /// Compute aggregate statistics for a village's decisions.
272    pub fn village_stats(
273        &self,
274        village_id: &str,
275        after: Option<&str>,
276        before: Option<&str>,
277    ) -> anyhow::Result<crate::domain::VillageStats> {
278        self.sqlite
279            .village_stats(village_id, after, before)
280            .with_context(|| format!("Ledger::village_stats({village_id})"))
281    }
282
283    /// Detect recurring patterns in a village's decision history.
284    pub fn detect_village_patterns(
285        &self,
286        village_id: &str,
287        after: &str,
288        min_occurrences: usize,
289    ) -> anyhow::Result<Vec<crate::domain::DetectedPattern>> {
290        self.sqlite
291            .detect_village_patterns(village_id, after, min_occurrences)
292            .with_context(|| format!("Ledger::detect_village_patterns({village_id})"))
293    }
294
295    /// Find the active decision for a specific key on a branch.
296    pub fn find_active_decision(
297        &self,
298        branch: &str,
299        key: &str,
300    ) -> anyhow::Result<Option<DecisionView>> {
301        let row = self
302            .sqlite
303            .find_active_decision(branch, key)
304            .with_context(|| format!("Ledger::find_active_decision(branch={branch}, key={key})"))?;
305        Ok(row.as_ref().map(view::to_view))
306    }
307
308    /// Return active decisions that have non-empty `affected_paths`.
309    /// Used by Injection to get the candidate set for glob matching.
310    pub fn query_active_with_paths(
311        &self,
312        branch: Option<&str>,
313        limit: Option<usize>,
314    ) -> anyhow::Result<Vec<DecisionView>> {
315        let rows = self
316            .sqlite
317            .active_decisions_with_paths(branch, limit)
318            .context("Ledger::query_active_with_paths")?;
319        Ok(rows.iter().map(view::to_view).collect())
320    }
321
322    /// Given a list of file paths, return decisions whose `affected_paths` globs
323    /// match any of them. Uses `globset` for pattern matching.
324    ///
325    /// This is the primary query for PreToolUse hook (Track E):
326    /// "which active decisions govern the file I'm about to edit?"
327    ///
328    /// # Arguments
329    /// - `paths`: concrete file paths to check (e.g., `["crates/edda-ledger/src/lib.rs"]`)
330    /// - `branch`: optional branch filter
331    /// - `limit`: max decisions to return
332    pub fn query_by_paths(
333        &self,
334        paths: &[&str],
335        branch: Option<&str>,
336        limit: Option<usize>,
337    ) -> anyhow::Result<Vec<DecisionView>> {
338        // 1. Get all active decisions that have affected_paths
339        let candidates = self.query_active_with_paths(branch, None)?;
340
341        // 2. Filter by glob match
342        let mut matched: Vec<DecisionView> = Vec::new();
343        for decision in candidates {
344            let dominated = decision.affected_paths.iter().any(|glob_pattern| {
345                match globset::Glob::new(glob_pattern) {
346                    Ok(glob) => {
347                        let matcher = glob.compile_matcher();
348                        paths.iter().any(|p| matcher.is_match(p))
349                    }
350                    Err(_) => false, // invalid glob -> skip silently
351                }
352            });
353            if dominated {
354                matched.push(decision);
355            }
356            if let Some(lim) = limit {
357                if matched.len() >= lim {
358                    break;
359                }
360            }
361        }
362
363        Ok(matched)
364    }
365
366    // ── Cross-Project Sync ────────────────────────────────────────────
367
368    /// Query active decisions with shared or global scope.
369    pub fn shared_decisions(&self) -> anyhow::Result<Vec<DecisionView>> {
370        let rows = self
371            .sqlite
372            .shared_decisions()
373            .context("Ledger::shared_decisions")?;
374        Ok(rows.iter().map(view::to_view).collect())
375    }
376
377    /// Check if a decision has already been imported from a source project.
378    pub fn is_already_imported(
379        &self,
380        source_project_id: &str,
381        source_event_id: &str,
382    ) -> anyhow::Result<bool> {
383        self.sqlite
384            .is_already_imported(source_project_id, source_event_id)
385            .context("Ledger::is_already_imported")
386    }
387
388    /// Insert an imported decision from another project.
389    pub fn insert_imported_decision(&self, params: crate::ImportParams<'_>) -> anyhow::Result<()> {
390        self.sqlite
391            .insert_imported_decision(params)
392            .context("Ledger::insert_imported_decision")
393    }
394
395    // ── Decision Dependencies ────────────────────────────────────────
396
397    /// Insert a dependency edge between two decision keys.
398    pub fn insert_dep(
399        &self,
400        source_key: &str,
401        target_key: &str,
402        dep_type: &str,
403        created_event: Option<&str>,
404    ) -> anyhow::Result<()> {
405        self.sqlite
406            .insert_dep(source_key, target_key, dep_type, created_event)
407            .with_context(|| format!("Ledger::insert_dep({source_key} -> {target_key})"))
408    }
409
410    /// What does `key` depend on?
411    pub fn deps_of(&self, key: &str) -> anyhow::Result<Vec<crate::DependencyEdge>> {
412        self.sqlite
413            .deps_of(key)
414            .with_context(|| format!("Ledger::deps_of({key})"))
415    }
416
417    /// Who depends on `key`?
418    pub fn dependents_of(&self, key: &str) -> anyhow::Result<Vec<crate::DependencyEdge>> {
419        self.sqlite
420            .dependents_of(key)
421            .with_context(|| format!("Ledger::dependents_of({key})"))
422    }
423
424    /// Who depends on `key`, joined with active decisions only.
425    pub fn active_dependents_of(
426        &self,
427        key: &str,
428    ) -> anyhow::Result<Vec<(crate::DependencyEdge, DecisionView)>> {
429        let rows = self
430            .sqlite
431            .active_dependents_of(key)
432            .with_context(|| format!("Ledger::active_dependents_of({key})"))?;
433        Ok(rows
434            .into_iter()
435            .map(|(dep, row)| (dep, view::to_view(&row)))
436            .collect())
437    }
438
439    // ── Decision Outcomes ─────────────────────────────────────────────
440
441    /// Get aggregated outcome metrics for a decision.
442    pub fn decision_outcomes(
443        &self,
444        decision_event_id: &str,
445    ) -> anyhow::Result<Option<crate::domain::OutcomeMetrics>> {
446        self.sqlite
447            .decision_outcomes(decision_event_id)
448            .with_context(|| format!("Ledger::decision_outcomes({decision_event_id})"))
449    }
450
451    /// Get all execution events linked to a decision via `based_on` provenance.
452    pub fn executions_for_decision(
453        &self,
454        decision_event_id: &str,
455    ) -> anyhow::Result<Vec<crate::domain::ExecutionLinked>> {
456        self.sqlite
457            .executions_for_decision(decision_event_id)
458            .with_context(|| format!("Ledger::executions_for_decision({decision_event_id})"))
459    }
460
461    /// Transitive dependents of `key` via BFS, up to `max_depth` hops.
462    /// Returns `(DependencyEdge, DecisionView, depth)` — only active decisions, deduplicated.
463    pub fn transitive_dependents_of(
464        &self,
465        key: &str,
466        max_depth: usize,
467    ) -> anyhow::Result<Vec<(crate::DependencyEdge, DecisionView, usize)>> {
468        let rows = self
469            .sqlite
470            .transitive_dependents_of(key, max_depth)
471            .with_context(|| format!("Ledger::transitive_dependents_of({key})"))?;
472        Ok(rows
473            .into_iter()
474            .map(|(dep, row, depth)| (dep, view::to_view(&row), depth))
475            .collect())
476    }
477
478    // ── Causal Chain ─────────────────────────────────────────────────
479
480    /// Look up a single decision by event_id.
481    pub fn get_decision_by_event_id(&self, event_id: &str) -> anyhow::Result<Option<DecisionView>> {
482        let row = self
483            .sqlite
484            .get_decision_by_event_id(event_id)
485            .with_context(|| format!("Ledger::get_decision_by_event_id({event_id})"))?;
486        Ok(row.as_ref().map(view::to_view))
487    }
488
489    /// Traverse the causal chain from a root decision via unified BFS.
490    pub fn causal_chain(
491        &self,
492        event_id: &str,
493        max_depth: usize,
494    ) -> anyhow::Result<Option<(DecisionView, Vec<crate::domain::ChainEntryView>)>> {
495        let result = self
496            .sqlite
497            .causal_chain(event_id, max_depth)
498            .with_context(|| format!("Ledger::causal_chain({event_id})"))?;
499        Ok(result.map(|(root, entries)| {
500            let root_view = view::to_view(&root);
501            let entry_views = entries
502                .into_iter()
503                .map(|e| crate::domain::ChainEntryView {
504                    decision: view::to_view(&e.decision),
505                    relation: e.relation,
506                    depth: e.depth,
507                })
508                .collect();
509            (root_view, entry_views)
510        }))
511    }
512
513    // ── Task Briefs ──────────────────────────────────────────────────
514
515    /// Get a task brief by task_id.
516    pub fn get_task_brief(&self, task_id: &str) -> anyhow::Result<Option<crate::TaskBriefRow>> {
517        self.sqlite
518            .get_task_brief(task_id)
519            .with_context(|| format!("Ledger::get_task_brief({task_id})"))
520    }
521
522    /// List task briefs, optionally filtered by status and/or intent.
523    pub fn list_task_briefs(
524        &self,
525        status: Option<&str>,
526        intent: Option<&str>,
527    ) -> anyhow::Result<Vec<crate::TaskBriefRow>> {
528        self.sqlite
529            .list_task_briefs(status, intent)
530            .context("Ledger::list_task_briefs")
531    }
532
533    // ── Review Bundles ───────────────────────────────────────────────
534
535    /// Get a review bundle by bundle_id.
536    pub fn get_bundle(&self, bundle_id: &str) -> anyhow::Result<Option<BundleRow>> {
537        self.sqlite
538            .get_bundle(bundle_id)
539            .with_context(|| format!("Ledger::get_bundle({bundle_id})"))
540    }
541
542    /// List review bundles, optionally filtered by status.
543    pub fn list_bundles(&self, status: Option<&str>) -> anyhow::Result<Vec<BundleRow>> {
544        self.sqlite
545            .list_bundles(status)
546            .context("Ledger::list_bundles")
547    }
548
549    // ── Device Tokens ───────────────────────────────────────────────
550
551    /// Insert a new device token row.
552    pub fn insert_device_token(&self, row: &crate::DeviceTokenRow) -> anyhow::Result<()> {
553        self.sqlite
554            .insert_device_token(row)
555            .context("Ledger::insert_device_token")
556    }
557
558    /// Validate a device token by its SHA-256 hash. Returns the row if active.
559    pub fn validate_device_token(
560        &self,
561        token_hash: &str,
562    ) -> anyhow::Result<Option<crate::DeviceTokenRow>> {
563        self.sqlite
564            .validate_device_token(token_hash)
565            .context("Ledger::validate_device_token")
566    }
567
568    /// List all device tokens (active and revoked).
569    pub fn list_device_tokens(&self) -> anyhow::Result<Vec<crate::DeviceTokenRow>> {
570        self.sqlite
571            .list_device_tokens()
572            .context("Ledger::list_device_tokens")
573    }
574
575    /// Revoke a device token by device name. Returns true if revoked.
576    pub fn revoke_device_token(
577        &self,
578        device_name: &str,
579        revoke_event_id: &str,
580    ) -> anyhow::Result<bool> {
581        self.sqlite
582            .revoke_device_token(device_name, revoke_event_id)
583            .with_context(|| format!("Ledger::revoke_device_token({device_name})"))
584    }
585
586    /// Revoke all active device tokens. Returns count of revoked tokens.
587    pub fn revoke_all_device_tokens(&self, revoke_event_id: &str) -> anyhow::Result<u64> {
588        self.sqlite
589            .revoke_all_device_tokens(revoke_event_id)
590            .context("Ledger::revoke_all_device_tokens")
591    }
592
593    // ── Decide Snapshots ────────────────────────────────────────────
594
595    /// Insert a row into the `decide_snapshots` materialized view.
596    pub fn insert_snapshot(&self, row: &crate::DecideSnapshotRow) -> anyhow::Result<()> {
597        self.sqlite
598            .insert_snapshot(row)
599            .context("Ledger::insert_snapshot")
600    }
601
602    /// Query snapshots with optional filtering by village_id and engine_version.
603    pub fn query_snapshots(
604        &self,
605        village_id: Option<&str>,
606        engine_version: Option<&str>,
607        limit: usize,
608    ) -> anyhow::Result<Vec<crate::DecideSnapshotRow>> {
609        self.sqlite
610            .query_snapshots(village_id, engine_version, limit)
611            .context("Ledger::query_snapshots")
612    }
613
614    /// Find all snapshots for a given context_hash.
615    pub fn snapshots_by_context_hash(
616        &self,
617        context_hash: &str,
618    ) -> anyhow::Result<Vec<crate::DecideSnapshotRow>> {
619        self.sqlite
620            .snapshots_by_context_hash(context_hash)
621            .with_context(|| format!("Ledger::snapshots_by_context_hash({context_hash})"))
622    }
623
624    // ── Suggestions ──────────────────────────────────────────────────
625
626    /// Insert a new suggestion row.
627    pub fn insert_suggestion(&self, row: &crate::SuggestionRow) -> anyhow::Result<()> {
628        self.sqlite
629            .insert_suggestion(row)
630            .context("Ledger::insert_suggestion")
631    }
632
633    /// List suggestions filtered by status.
634    pub fn list_suggestions_by_status(
635        &self,
636        status: &str,
637    ) -> anyhow::Result<Vec<crate::SuggestionRow>> {
638        self.sqlite
639            .list_suggestions_by_status(status)
640            .with_context(|| format!("Ledger::list_suggestions_by_status({status})"))
641    }
642
643    /// Get a single suggestion by id.
644    pub fn get_suggestion(&self, id: &str) -> anyhow::Result<Option<crate::SuggestionRow>> {
645        self.sqlite
646            .get_suggestion(id)
647            .with_context(|| format!("Ledger::get_suggestion({id})"))
648    }
649
650    /// Update a suggestion's status and reviewed_at timestamp.
651    /// Returns true if a row was updated.
652    pub fn update_suggestion_status(
653        &self,
654        id: &str,
655        status: &str,
656        reviewed_at: &str,
657    ) -> anyhow::Result<bool> {
658        self.sqlite
659            .update_suggestion_status(id, status, reviewed_at)
660            .with_context(|| format!("Ledger::update_suggestion_status({id})"))
661    }
662}
663
664// ── Init functions ──────────────────────────────────────────────────
665
666/// Initialize a new workspace from `EddaPaths`. Used by `cmd_init`.
667///
668/// Creates the directory layout AND a fresh `ledger.db` with schema.
669pub fn init_workspace(paths: &EddaPaths) -> anyhow::Result<()> {
670    paths.ensure_layout()?;
671    std::fs::create_dir_all(paths.branch_dir("main"))?;
672    SqliteStore::open_or_create(&paths.ledger_db)?;
673    Ok(())
674}
675
676/// Write the initial HEAD into SQLite.
677pub fn init_head(paths: &EddaPaths, branch: &str) -> anyhow::Result<()> {
678    let store = SqliteStore::open(&paths.ledger_db)?;
679    if store.head_branch().is_err() {
680        store.set_head_branch(branch)?;
681    }
682    Ok(())
683}
684
685/// Write initial branches.json into SQLite.
686pub fn init_branches_json(paths: &EddaPaths, branch: &str) -> anyhow::Result<()> {
687    let now = time_now_rfc3339();
688    let json = serde_json::json!({
689        "branches": {
690            branch: {
691                "created_at": now
692            }
693        }
694    });
695    let store = SqliteStore::open(&paths.ledger_db)?;
696    if store.branches_json().is_err() {
697        store.set_branches_json(&json)?;
698    }
699    Ok(())
700}
701
702fn time_now_rfc3339() -> String {
703    let now = time::OffsetDateTime::now_utc();
704    now.format(&time::format_description::well_known::Rfc3339)
705        .expect("RFC3339 formatting should not fail")
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use edda_core::event::new_note_event;
712    use std::sync::atomic::{AtomicU64, Ordering};
713
714    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
715
716    fn setup_workspace() -> (std::path::PathBuf, Ledger) {
717        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
718        let tmp = std::env::temp_dir().join(format!("edda_ledger_test_{}_{n}", std::process::id()));
719        let _ = std::fs::remove_dir_all(&tmp);
720        let paths = EddaPaths::discover(&tmp);
721        init_workspace(&paths).unwrap();
722        init_head(&paths, "main").unwrap();
723        init_branches_json(&paths, "main").unwrap();
724        let ledger = Ledger::open(&tmp).unwrap();
725        (tmp, ledger)
726    }
727
728    #[test]
729    fn empty_ledger_has_no_hash() {
730        let (tmp, ledger) = setup_workspace();
731        assert_eq!(ledger.last_event_hash().unwrap(), None);
732        let _ = std::fs::remove_dir_all(&tmp);
733    }
734
735    #[test]
736    fn append_and_read_back() {
737        let (tmp, ledger) = setup_workspace();
738        let e1 = new_note_event("main", None, "system", "init", &[]).unwrap();
739        ledger.append_event(&e1).unwrap();
740        assert_eq!(ledger.last_event_hash().unwrap(), Some(e1.hash.clone()));
741
742        let e2 = new_note_event("main", Some(&e1.hash), "user", "hello", &[]).unwrap();
743        ledger.append_event(&e2).unwrap();
744        assert_eq!(ledger.last_event_hash().unwrap(), Some(e2.hash.clone()));
745
746        let events = ledger.iter_events().unwrap();
747        assert_eq!(events.len(), 2);
748        assert_eq!(events[0].event_id, e1.event_id);
749        assert_eq!(events[1].event_id, e2.event_id);
750        assert_eq!(events[1].parent_hash.as_deref(), Some(e1.hash.as_str()));
751
752        let _ = std::fs::remove_dir_all(&tmp);
753    }
754
755    #[test]
756    fn head_branch_read_write() {
757        let (tmp, ledger) = setup_workspace();
758        assert_eq!(ledger.head_branch().unwrap(), "main");
759        ledger.set_head_branch("feat/x").unwrap();
760        assert_eq!(ledger.head_branch().unwrap(), "feat/x");
761        let _ = std::fs::remove_dir_all(&tmp);
762    }
763
764    #[test]
765    fn branches_json_read_write() {
766        let (tmp, ledger) = setup_workspace();
767        let bj = ledger.branches_json().unwrap();
768        assert!(bj["branches"]["main"].is_object());
769
770        let new_json = serde_json::json!({
771            "branches": {
772                "main": { "created_at": "2026-01-01T00:00:00Z" },
773                "dev": { "created_at": "2026-02-01T00:00:00Z" }
774            }
775        });
776        ledger.set_branches_json(&new_json).unwrap();
777        let loaded = ledger.branches_json().unwrap();
778        assert_eq!(loaded, new_json);
779
780        let _ = std::fs::remove_dir_all(&tmp);
781    }
782
783    #[test]
784    fn open_or_init_creates_workspace() {
785        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
786        let tmp = std::env::temp_dir().join(format!("edda_auto_init_{}_{n}", std::process::id()));
787        let _ = std::fs::remove_dir_all(&tmp);
788        std::fs::create_dir_all(&tmp).unwrap();
789
790        // No .edda/ exists yet
791        assert!(!tmp.join(".edda").exists());
792
793        // open_or_init should create it
794        let ledger = Ledger::open_or_init(&tmp).unwrap();
795        assert!(tmp.join(".edda").exists());
796        assert_eq!(ledger.head_branch().unwrap(), "main");
797
798        // Second call is idempotent
799        let ledger2 = Ledger::open_or_init(&tmp).unwrap();
800        assert_eq!(ledger2.head_branch().unwrap(), "main");
801
802        let _ = std::fs::remove_dir_all(&tmp);
803    }
804
805    #[test]
806    fn open_without_init_fails() {
807        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
808        let tmp = std::env::temp_dir().join(format!("edda_no_init_{}_{n}", std::process::id()));
809        let _ = std::fs::remove_dir_all(&tmp);
810        std::fs::create_dir_all(&tmp).unwrap();
811        assert!(Ledger::open(&tmp).is_err());
812        let _ = std::fs::remove_dir_all(&tmp);
813    }
814
815    // ── transitive_dependents_of tests ─────────────────────────────
816
817    fn make_decision_event(branch: &str, key: &str, value: &str) -> edda_core::Event {
818        use edda_core::event::finalize_event;
819        let text = format!("{key}: {value}");
820        let tags = vec!["decision".to_string()];
821        let mut event = new_note_event(branch, None, "system", &text, &tags).unwrap();
822        event.payload["decision"] = serde_json::json!({"key": key, "value": value});
823        finalize_event(&mut event).unwrap();
824        event
825    }
826
827    #[test]
828    fn transitive_dependents_chain() {
829        let (tmp, ledger) = setup_workspace();
830
831        // A -> B -> C chain
832        ledger
833            .append_event(&make_decision_event("main", "a.root", "v1"))
834            .unwrap();
835        ledger
836            .append_event(&make_decision_event("main", "b.mid", "v2"))
837            .unwrap();
838        ledger
839            .append_event(&make_decision_event("main", "c.leaf", "v3"))
840            .unwrap();
841
842        ledger
843            .insert_dep("b.mid", "a.root", "explicit", None)
844            .unwrap();
845        ledger
846            .insert_dep("c.leaf", "b.mid", "explicit", None)
847            .unwrap();
848
849        let deps = ledger.transitive_dependents_of("a.root", 3).unwrap();
850        assert_eq!(deps.len(), 2);
851
852        let b = deps.iter().find(|(_, d, _)| d.key == "b.mid").unwrap();
853        assert_eq!(b.2, 1);
854
855        let c = deps.iter().find(|(_, d, _)| d.key == "c.leaf").unwrap();
856        assert_eq!(c.2, 2);
857
858        let _ = std::fs::remove_dir_all(&tmp);
859    }
860
861    #[test]
862    fn transitive_dependents_diamond_no_duplicates() {
863        let (tmp, ledger) = setup_workspace();
864
865        // A -> B, A -> C, B -> D, C -> D
866        for (k, v) in [
867            ("a.root", "v1"),
868            ("b.left", "v2"),
869            ("c.right", "v3"),
870            ("d.leaf", "v4"),
871        ] {
872            ledger
873                .append_event(&make_decision_event("main", k, v))
874                .unwrap();
875        }
876        ledger
877            .insert_dep("b.left", "a.root", "explicit", None)
878            .unwrap();
879        ledger
880            .insert_dep("c.right", "a.root", "explicit", None)
881            .unwrap();
882        ledger
883            .insert_dep("d.leaf", "b.left", "explicit", None)
884            .unwrap();
885        ledger
886            .insert_dep("d.leaf", "c.right", "explicit", None)
887            .unwrap();
888
889        let deps = ledger.transitive_dependents_of("a.root", 3).unwrap();
890        // Should have B, C (depth 1) and D (depth 2) — no duplicates
891        assert_eq!(deps.len(), 3);
892
893        let d_hits: Vec<_> = deps.iter().filter(|(_, d, _)| d.key == "d.leaf").collect();
894        assert_eq!(d_hits.len(), 1, "d.leaf should appear only once");
895        assert_eq!(d_hits[0].2, 2);
896
897        let _ = std::fs::remove_dir_all(&tmp);
898    }
899
900    #[test]
901    fn transitive_dependents_depth_limit() {
902        let (tmp, ledger) = setup_workspace();
903
904        // Chain of 5: a -> b -> c -> d -> e
905        for (k, v) in [
906            ("a.n1", "v1"),
907            ("b.n2", "v2"),
908            ("c.n3", "v3"),
909            ("d.n4", "v4"),
910            ("e.n5", "v5"),
911        ] {
912            ledger
913                .append_event(&make_decision_event("main", k, v))
914                .unwrap();
915        }
916        ledger.insert_dep("b.n2", "a.n1", "explicit", None).unwrap();
917        ledger.insert_dep("c.n3", "b.n2", "explicit", None).unwrap();
918        ledger.insert_dep("d.n4", "c.n3", "explicit", None).unwrap();
919        ledger.insert_dep("e.n5", "d.n4", "explicit", None).unwrap();
920
921        // Limit to 2 hops
922        let deps = ledger.transitive_dependents_of("a.n1", 2).unwrap();
923        assert_eq!(deps.len(), 2, "depth limit 2 should return only 2 hops");
924
925        let keys: Vec<&str> = deps.iter().map(|(_, d, _)| d.key.as_str()).collect();
926        assert!(keys.contains(&"b.n2"));
927        assert!(keys.contains(&"c.n3"));
928
929        let _ = std::fs::remove_dir_all(&tmp);
930    }
931
932    #[test]
933    fn transitive_dependents_empty() {
934        let (tmp, ledger) = setup_workspace();
935        ledger
936            .append_event(&make_decision_event("main", "solo.key", "val"))
937            .unwrap();
938
939        let deps = ledger.transitive_dependents_of("solo.key", 3).unwrap();
940        assert!(deps.is_empty());
941
942        let _ = std::fs::remove_dir_all(&tmp);
943    }
944
945    #[test]
946    fn append_event_idempotent_dedup() {
947        let (tmp, ledger) = setup_workspace();
948        let event = new_note_event("main", None, "system", "test idempotent", &[]).unwrap();
949
950        // First insert returns true
951        let inserted = ledger.append_event_idempotent(&event).unwrap();
952        assert!(inserted);
953
954        // Duplicate insert returns false (no error)
955        let inserted2 = ledger.append_event_idempotent(&event).unwrap();
956        assert!(!inserted2);
957
958        // Only one event in ledger
959        let events = ledger.iter_events().unwrap();
960        assert_eq!(events.len(), 1);
961        assert_eq!(events[0].event_id, event.event_id);
962
963        let _ = std::fs::remove_dir_all(&tmp);
964    }
965
966    #[test]
967    fn iter_events_by_type_filters_correctly() {
968        use edda_core::event::new_execution_event;
969
970        let (tmp, ledger) = setup_workspace();
971        let note = new_note_event("main", None, "system", "a note", &[]).unwrap();
972        ledger.append_event(&note).unwrap();
973
974        let payload = serde_json::json!({
975            "runtime": "claude", "model": "claude-3-opus",
976            "usage": { "token_in": 100, "token_out": 50, "cost_usd": 0.01, "latency_ms": 500 },
977            "result": { "status": "success" },
978            "event_type": "step_completed",
979        });
980        let exec = new_execution_event(
981            "main",
982            Some(&note.hash),
983            "evt_exec_1",
984            "2026-03-11T00:00:00Z",
985            payload,
986            None,
987        )
988        .unwrap();
989        ledger.append_event(&exec).unwrap();
990
991        assert_eq!(ledger.iter_events().unwrap().len(), 2);
992        let exec_events = ledger.iter_events_by_type("execution_event").unwrap();
993        assert_eq!(exec_events.len(), 1);
994        assert_eq!(exec_events[0].event_id, "evt_exec_1");
995        let note_events = ledger.iter_events_by_type("note").unwrap();
996        assert_eq!(note_events.len(), 1);
997        assert_eq!(note_events[0].event_id, note.event_id);
998
999        let _ = std::fs::remove_dir_all(&tmp);
1000    }
1001
1002    #[test]
1003    fn iter_events_by_type_empty_for_unknown() {
1004        let (tmp, ledger) = setup_workspace();
1005        let note = new_note_event("main", None, "system", "a note", &[]).unwrap();
1006        ledger.append_event(&note).unwrap();
1007        let result = ledger.iter_events_by_type("nonexistent_type").unwrap();
1008        assert!(result.is_empty());
1009        let _ = std::fs::remove_dir_all(&tmp);
1010    }
1011
1012    // ── Device Token tests ──────────────────────────────────────────
1013
1014    /// Helper: append a note event and return its event_id (satisfies FK on device_tokens).
1015    fn insert_pair_event(ledger: &Ledger) -> String {
1016        let evt = new_note_event("main", None, "system", "device_pair placeholder", &[]).unwrap();
1017        let eid = evt.event_id.clone();
1018        ledger.append_event(&evt).unwrap();
1019        eid
1020    }
1021
1022    #[test]
1023    fn device_token_insert_and_validate() {
1024        use crate::device_token::{generate_device_token, hash_token};
1025
1026        let (tmp, ledger) = setup_workspace();
1027        let pair_eid = insert_pair_event(&ledger);
1028
1029        let raw = generate_device_token();
1030        let hashed = hash_token(&raw);
1031
1032        ledger
1033            .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1034                token_hash: hashed.clone(),
1035                device_name: "test-phone".to_string(),
1036                paired_at: "2026-01-01T00:00:00Z".to_string(),
1037                paired_from_ip: "127.0.0.1".to_string(),
1038                revoked_at: None,
1039                pair_event_id: pair_eid,
1040                revoke_event_id: None,
1041            })
1042            .unwrap();
1043
1044        // Validate with correct hash succeeds
1045        let found = ledger.validate_device_token(&hashed).unwrap();
1046        assert!(found.is_some());
1047        assert_eq!(found.unwrap().device_name, "test-phone");
1048
1049        // Validate with wrong hash returns None
1050        let wrong = ledger.validate_device_token("badhash").unwrap();
1051        assert!(wrong.is_none());
1052
1053        let _ = std::fs::remove_dir_all(&tmp);
1054    }
1055
1056    #[test]
1057    fn device_token_revoke_flow() {
1058        use crate::device_token::{generate_device_token, hash_token};
1059
1060        let (tmp, ledger) = setup_workspace();
1061        let pair_eid = insert_pair_event(&ledger);
1062
1063        let raw = generate_device_token();
1064        let hashed = hash_token(&raw);
1065
1066        ledger
1067            .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1068                token_hash: hashed.clone(),
1069                device_name: "my-tablet".to_string(),
1070                paired_at: "2026-01-01T00:00:00Z".to_string(),
1071                paired_from_ip: "127.0.0.1".to_string(),
1072                revoked_at: None,
1073                pair_event_id: pair_eid,
1074                revoke_event_id: None,
1075            })
1076            .unwrap();
1077
1078        // Token is valid before revocation
1079        assert!(ledger.validate_device_token(&hashed).unwrap().is_some());
1080
1081        // Revoke it
1082        let revoked = ledger.revoke_device_token("my-tablet", "evt_rev1").unwrap();
1083        assert!(revoked);
1084
1085        // Token is no longer valid after revocation
1086        assert!(ledger.validate_device_token(&hashed).unwrap().is_none());
1087
1088        // Revoking again returns false (already revoked)
1089        let revoked2 = ledger.revoke_device_token("my-tablet", "evt_rev2").unwrap();
1090        assert!(!revoked2);
1091
1092        // Revoking nonexistent device returns false
1093        let revoked3 = ledger
1094            .revoke_device_token("no-such-device", "evt_rev3")
1095            .unwrap();
1096        assert!(!revoked3);
1097
1098        let _ = std::fs::remove_dir_all(&tmp);
1099    }
1100
1101    #[test]
1102    fn device_token_revoke_all() {
1103        use crate::device_token::{generate_device_token, hash_token};
1104
1105        let (tmp, ledger) = setup_workspace();
1106
1107        // Insert two devices, each with a real event for the FK
1108        for name in ["dev-a", "dev-b"] {
1109            let pair_eid = insert_pair_event(&ledger);
1110            let raw = generate_device_token();
1111            let hashed = hash_token(&raw);
1112            ledger
1113                .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1114                    token_hash: hashed,
1115                    device_name: name.to_string(),
1116                    paired_at: "2026-01-01T00:00:00Z".to_string(),
1117                    paired_from_ip: "127.0.0.1".to_string(),
1118                    revoked_at: None,
1119                    pair_event_id: pair_eid,
1120                    revoke_event_id: None,
1121                })
1122                .unwrap();
1123        }
1124
1125        let tokens = ledger.list_device_tokens().unwrap();
1126        assert_eq!(tokens.len(), 2);
1127        let active = tokens.iter().filter(|t| t.revoked_at.is_none()).count();
1128        assert_eq!(active, 2);
1129
1130        // Revoke all
1131        let count = ledger.revoke_all_device_tokens("evt_revall").unwrap();
1132        assert_eq!(count, 2);
1133
1134        // All revoked
1135        let tokens2 = ledger.list_device_tokens().unwrap();
1136        let active2 = tokens2.iter().filter(|t| t.revoked_at.is_none()).count();
1137        assert_eq!(active2, 0);
1138
1139        let _ = std::fs::remove_dir_all(&tmp);
1140    }
1141
1142    #[test]
1143    fn device_token_list_shows_all() {
1144        use crate::device_token::{generate_device_token, hash_token};
1145
1146        let (tmp, ledger) = setup_workspace();
1147
1148        let pair_eid1 = insert_pair_event(&ledger);
1149        let pair_eid2 = insert_pair_event(&ledger);
1150
1151        let raw1 = generate_device_token();
1152        let raw2 = generate_device_token();
1153
1154        ledger
1155            .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1156                token_hash: hash_token(&raw1),
1157                device_name: "active-dev".to_string(),
1158                paired_at: "2026-01-01T00:00:00Z".to_string(),
1159                paired_from_ip: "127.0.0.1".to_string(),
1160                revoked_at: None,
1161                pair_event_id: pair_eid1,
1162                revoke_event_id: None,
1163            })
1164            .unwrap();
1165
1166        ledger
1167            .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1168                token_hash: hash_token(&raw2),
1169                device_name: "revoked-dev".to_string(),
1170                paired_at: "2026-01-01T00:00:00Z".to_string(),
1171                paired_from_ip: "192.168.1.1".to_string(),
1172                revoked_at: None,
1173                pair_event_id: pair_eid2,
1174                revoke_event_id: None,
1175            })
1176            .unwrap();
1177
1178        // Revoke the second one
1179        ledger
1180            .revoke_device_token("revoked-dev", "evt_rev")
1181            .unwrap();
1182
1183        let all = ledger.list_device_tokens().unwrap();
1184        assert_eq!(all.len(), 2, "list should include both active and revoked");
1185
1186        let active: Vec<_> = all.iter().filter(|t| t.revoked_at.is_none()).collect();
1187        assert_eq!(active.len(), 1);
1188        assert_eq!(active[0].device_name, "active-dev");
1189
1190        let _ = std::fs::remove_dir_all(&tmp);
1191    }
1192
1193    // ── query_by_paths tests ─────────────────────────────────────────
1194
1195    /// Helper: create a decision event and set its affected_paths via raw SQL.
1196    fn insert_decision_with_paths(
1197        ledger: &Ledger,
1198        key: &str,
1199        value: &str,
1200        paths: &[&str],
1201    ) -> String {
1202        let event = make_decision_event("main", key, value);
1203        let event_id = event.event_id.clone();
1204        ledger.append_event(&event).unwrap();
1205
1206        // Update affected_paths via raw SQL (materialization doesn't set it from payload)
1207        let paths_json = serde_json::to_string(paths).unwrap();
1208        let conn = rusqlite::Connection::open(&ledger.paths.ledger_db).unwrap();
1209        conn.execute(
1210            "UPDATE decisions SET affected_paths = ?1 WHERE event_id = ?2",
1211            rusqlite::params![paths_json, event_id],
1212        )
1213        .unwrap();
1214
1215        event_id
1216    }
1217
1218    #[test]
1219    fn query_by_paths_glob_matches() {
1220        let (tmp, ledger) = setup_workspace();
1221        insert_decision_with_paths(&ledger, "db.engine", "sqlite", &["crates/edda-ledger/**"]);
1222
1223        let results = ledger
1224            .query_by_paths(&["crates/edda-ledger/src/lib.rs"], Some("main"), None)
1225            .unwrap();
1226
1227        assert_eq!(results.len(), 1);
1228        assert_eq!(results[0].key, "db.engine");
1229
1230        let _ = std::fs::remove_dir_all(&tmp);
1231    }
1232
1233    #[test]
1234    fn query_by_paths_no_match() {
1235        let (tmp, ledger) = setup_workspace();
1236        insert_decision_with_paths(&ledger, "db.engine", "sqlite", &["crates/edda-ledger/**"]);
1237
1238        let results = ledger
1239            .query_by_paths(
1240                &["crates/edda-cli/src/main.rs"], // doesn't match ledger glob
1241                Some("main"),
1242                None,
1243            )
1244            .unwrap();
1245
1246        assert!(results.is_empty());
1247
1248        let _ = std::fs::remove_dir_all(&tmp);
1249    }
1250
1251    #[test]
1252    fn query_by_paths_skips_superseded() {
1253        let (tmp, ledger) = setup_workspace();
1254
1255        // First decision: db.engine=postgres
1256        insert_decision_with_paths(&ledger, "db.engine", "postgres", &["crates/**"]);
1257
1258        // Second decision supersedes first (same key+branch → first becomes is_active=FALSE)
1259        insert_decision_with_paths(&ledger, "db.engine", "sqlite", &["crates/**"]);
1260
1261        let results = ledger
1262            .query_by_paths(&["crates/edda-ledger/src/lib.rs"], Some("main"), None)
1263            .unwrap();
1264
1265        // Only the active decision should appear
1266        assert_eq!(results.len(), 1);
1267        assert_eq!(results[0].value, "sqlite");
1268
1269        let _ = std::fs::remove_dir_all(&tmp);
1270    }
1271
1272    #[test]
1273    fn query_by_paths_multiple_globs() {
1274        let (tmp, ledger) = setup_workspace();
1275        insert_decision_with_paths(
1276            &ledger,
1277            "error.pattern",
1278            "thiserror",
1279            &["crates/edda-ledger/**", "crates/edda-core/**"],
1280        );
1281
1282        // Match on second glob
1283        let results = ledger
1284            .query_by_paths(&["crates/edda-core/src/types.rs"], Some("main"), None)
1285            .unwrap();
1286
1287        assert_eq!(results.len(), 1);
1288        assert_eq!(results[0].key, "error.pattern");
1289
1290        let _ = std::fs::remove_dir_all(&tmp);
1291    }
1292
1293    #[test]
1294    fn query_by_paths_respects_limit() {
1295        let (tmp, ledger) = setup_workspace();
1296
1297        // Insert 3 decisions all matching "crates/**"
1298        insert_decision_with_paths(&ledger, "db.engine", "sqlite", &["crates/**"]);
1299        insert_decision_with_paths(&ledger, "error.pattern", "thiserror", &["crates/**"]);
1300        insert_decision_with_paths(&ledger, "auth.strategy", "jwt", &["crates/**"]);
1301
1302        let results = ledger
1303            .query_by_paths(&["crates/edda-ledger/src/lib.rs"], Some("main"), Some(2))
1304            .unwrap();
1305
1306        assert!(results.len() <= 2);
1307
1308        let _ = std::fs::remove_dir_all(&tmp);
1309    }
1310
1311    #[test]
1312    fn query_active_with_paths_excludes_empty() {
1313        let (tmp, ledger) = setup_workspace();
1314
1315        // Decision without paths (defaults to "[]")
1316        ledger
1317            .append_event(&make_decision_event("main", "no.paths", "val"))
1318            .unwrap();
1319
1320        // Decision with paths
1321        insert_decision_with_paths(&ledger, "has.paths", "val", &["src/**"]);
1322
1323        let results = ledger.query_active_with_paths(Some("main"), None).unwrap();
1324        assert_eq!(results.len(), 1);
1325        assert_eq!(results[0].key, "has.paths");
1326
1327        let _ = std::fs::remove_dir_all(&tmp);
1328    }
1329}