Skip to main content

agentstatedeveloper_core/
conclusions_export.rs

1//! Plan B t-004: shared conclusion-export pipeline used by both the CLI
2//! (`asd conclusions export`) and MCP (`conclusions_export` tool).
3//!
4//! Walks every indexed symbol, collects ledger entries, buckets them by
5//! `ConclusionClass`, and writes one compact, byte-stable JSONL file per
6//! class. The output is intentionally minimal — see DESIGN.md "Plan B" for
7//! the field layout and rationale.
8
9use std::collections::BTreeMap;
10use std::io::Write as _;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15use crate::engine::Engine;
16use crate::index::{AsgIndexStore, IndexStore};
17use crate::ledger::{AsgLedgerStore, LedgerStore};
18use crate::paths::ASD_ROOT;
19use crate::schema::{AuthorKind, ConclusionClass, LedgerKind};
20use crate::sidecar_config::{ShardBy, SidecarConfig, package_key_for_filename};
21use crate::thinking::DEFAULT_CONFIDENCE_FLOOR;
22
23/// Compact JSONL record. `preserve_order` is on for serde_json in this
24/// workspace so field order is stable across runs. Plan B t-005 requires
25/// Deserialize too so import can round-trip.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExportRecord {
28    pub id: String,
29    pub kind: String,
30    pub qname: String,
31    pub file: String,
32    pub summary: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub body: Option<String>,
35    /// Plan T t-007: `LedgerEntry.confidence` (0.0–1.0). Load-bearing
36    /// for the Plan G thinking class (Hypothesis confidence gates both
37    /// the export floor and read-side `gather_prior_thinking`), but
38    /// carried for every kind so no class drops it on round-trip.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub confidence: Option<f64>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub role: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub command: Option<String>,
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub tags: Vec<String>,
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub evidence: Vec<serde_json::Value>,
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub supersedes: Vec<String>,
51    pub author: String,
52    pub created_at: String,
53}
54
55/// Walk the index, bucket every ledger entry by `ConclusionClass`. Each
56/// bucket is sorted by `id` alone — entry_ids are deterministic hashes
57/// (blake3-derived for Plan G thinking, content-derived for others), so
58/// id-sort gives:
59///
60/// 1. **Byte-identical output across runs** when the entry set is
61///    unchanged (re-export is a no-op).
62/// 2. **Conflict-resistant git merges**: hash-distributed positions
63///    mean two developers' concurrent same-day inserts land in
64///    different file regions and don't textually collide. Plan K
65///    t-001.
66///
67/// The previous sort key was `(created_at, id)`. That gave the same
68/// byte-stability but clustered same-day inserts together, raising the
69/// odds of merge conflicts when two devs added entries on the same day.
70/// id-only loses the time-ordered visual diff but gains real
71/// conflict-resistance — the Plan K principle (sidecar = judgment;
72/// conflicts must be meaningful) prefers the latter.
73///
74/// Upgrade note: re-exporting an existing project at >=1.0.36 will
75/// produce a one-time mass reorder of every `.asd/conclusions/*.jsonl`
76/// file. Commit it once, then steady state.
77/// What a gather produced, plus what it deliberately withheld.
78pub struct Gathered {
79    pub buckets: BTreeMap<&'static str, Vec<ExportRecord>>,
80    /// Entry ids excluded on purpose — superseded, or a hypothesis below the
81    /// confidence floor. Everything else missing from `buckets` is missing
82    /// because this clone could not produce it, not because it decided not to.
83    pub retired: std::collections::HashSet<String>,
84}
85
86/// Back-compat wrapper: the buckets alone.
87pub fn gather_buckets(
88    engine: &Engine,
89) -> std::io::Result<BTreeMap<&'static str, Vec<ExportRecord>>> {
90    Ok(gather(engine)?.buckets)
91}
92
93pub fn gather(engine: &Engine) -> std::io::Result<Gathered> {
94    let index = AsgIndexStore::from_engine(engine);
95    let ref_name = engine.ref_name.clone();
96
97    let mut buckets: BTreeMap<&'static str, Vec<ExportRecord>> = BTreeMap::new();
98    for class in ConclusionClass::all() {
99        buckets.insert(class.filename_stem(), Vec::new());
100    }
101    // Ids this clone excluded ON PURPOSE. Distinct from ids it simply could
102    // not produce (an orphaned symbol, or a record another branch owns) —
103    // see `merge_preserving`.
104    let mut retired: std::collections::HashSet<String> = std::collections::HashSet::new();
105
106    // Drive the walk from the LEDGER tree, not the symbol index. Nearly
107    // every symbol in a large repo carries zero conclusions, and the
108    // per-symbol `list_entries` falls through to an authoritative git
109    // `get_json` on a cache-miss (count == 0) — so iterating all 97k+
110    // symbols meant ~97k git probes to collect a few hundred entries.
111    // The ledger tree has one child per symbol that ACTUALLY has an
112    // entry, so this is O(symbols_with_conclusions), matching
113    // `detect_orphaned_entries`. Resolve qname/file via the cached
114    // symbol_id → Symbol map (one SQLite-backed build, no per-symbol
115    // git reads).
116    let id_map = index.build_id_map(engine);
117    let ledger_prefix = format!("{}/ledger", ASD_ROOT);
118    let ledger_tree = engine
119        .repo
120        .get_tree(&ref_name, &ledger_prefix)
121        .unwrap_or(serde_json::Value::Null);
122    let by_symbol = match ledger_tree {
123        serde_json::Value::Object(map) => map,
124        _ => serde_json::Map::new(),
125    };
126
127    // Sort symbol_ids for deterministic traversal (final output is
128    // id-sorted below, but a stable walk keeps behavior reproducible).
129    let mut symbol_ids: Vec<String> = by_symbol.keys().cloned().collect();
130    symbol_ids.sort();
131
132    for symbol_id in symbol_ids {
133        let sym = match id_map.get(&symbol_id) {
134            Some(s) => s,
135            // Orphaned ledger entry: symbol no longer in the index.
136            // Matches the prior behavior, which skipped any qname that
137            // didn't resolve to a symbol.
138            None => continue,
139        };
140        let per_symbol = match by_symbol.get(&symbol_id) {
141            Some(serde_json::Value::Object(m)) => m,
142            _ => continue,
143        };
144        // Parse entries and apply the same superseded-entry filter that
145        // `LedgerStore::list_entries` applies (drop any entry that a
146        // later entry supersedes).
147        let mut entries: Vec<crate::schema::LedgerEntry> = per_symbol
148            .values()
149            .filter_map(|v| serde_json::from_value(v.clone()).ok())
150            .collect();
151        let superseded: std::collections::HashSet<String> = entries
152            .iter()
153            .flat_map(|e| e.supersedes.iter().cloned())
154            .collect();
155        // A superseded entry is RETIRED ON PURPOSE — record that, so the
156        // additive merge below does not resurrect it from the committed file.
157        for e in entries.iter().filter(|e| superseded.contains(&e.entry_id)) {
158            retired.insert(e.entry_id.clone());
159        }
160        entries.retain(|e| !superseded.contains(&e.entry_id));
161        for entry in entries {
162            // Plan K t-003: confidence-floor filter at sync time.
163            // Hypotheses below DEFAULT_CONFIDENCE_FLOOR (0.3) are
164            // speculative scratch — still useful locally via
165            // `asd think list`, but not durable enough to ship as
166            // inherited team judgment. Same threshold as the
167            // read-side `gather_prior_thinking` so a new dev sees
168            // exactly what the committed sidecar would carry. Only
169            // the Hypothesis kind has a confidence semantic worth
170            // gating on; Decision/Constraint/etc. carry the same
171            // field but the meaning is different (recorded judgment
172            // regardless of certainty) so they always ship.
173            if entry.kind == LedgerKind::Hypothesis
174                && entry.confidence.unwrap_or(0.0) < DEFAULT_CONFIDENCE_FLOOR
175            {
176                // Also deliberate: a hypothesis that fell below the floor is
177                // meant to stop shipping, so it must not be resurrected.
178                retired.insert(entry.entry_id.clone());
179                continue;
180            }
181            let class = entry.kind.conclusion_class();
182            let stem = class.filename_stem();
183            let record = ExportRecord {
184                id: entry.entry_id,
185                kind: entry.kind.as_str().to_string(),
186                qname: sym.qname.clone(),
187                file: sym.file.clone(),
188                summary: entry.summary,
189                body: entry.body,
190                confidence: entry.confidence,
191                role: entry.role,
192                command: entry.command,
193                tags: entry.tags,
194                evidence: entry
195                    .evidence
196                    .into_iter()
197                    .map(|e| serde_json::to_value(e).unwrap_or(serde_json::Value::Null))
198                    .collect(),
199                supersedes: entry.supersedes,
200                author: format!("{}:{}", author_kind_str(entry.author.kind), entry.author.id),
201                created_at: entry.created_at.to_rfc3339(),
202            };
203            if let Some(bucket) = buckets.get_mut(stem) {
204                bucket.push(record);
205            }
206        }
207    }
208
209    // Plan K t-001: sort by id alone (deterministic hash). See the
210    // `gather_buckets` docstring for the rationale.
211    for bucket in buckets.values_mut() {
212        bucket.sort_by(|a, b| a.id.cmp(&b.id));
213    }
214    Ok(Gathered { buckets, retired })
215}
216
217/// Fold the records already committed at `path` back into a freshly gathered
218/// bucket, so the sidecar is additive rather than a snapshot of one clone.
219///
220/// Why this exists
221/// ---------------
222/// The export writes from THIS clone's database. Two things routinely make
223/// that a subset of what the committed file holds, and in both cases writing
224/// the bare export deletes records nobody meant to delete:
225///
226/// * **Orphaned symbols.** A ledger entry anchored to a `:line`-disambiguated
227///   qname (`Foo:237`) stops resolving the moment the code shifts, and
228///   `gather` skips it. That is how `led_9a6e25e92e8b…` vanished from main —
229///   the symbol became `ApiError:269` and the conclusion had nowhere to hang.
230/// * **A branch that predates someone else's merge.** Records another branch
231///   contributed were never in this clone's database at all.
232///
233/// What it must NOT do is resurrect a record this clone retired ON PURPOSE —
234/// superseded by a later entry, or a hypothesis that fell below the
235/// confidence floor. Hence `retired`: a committed record is preserved only
236/// when the local store expressed no such intent about it.
237pub fn merge_preserving(
238    fresh: &[ExportRecord],
239    path: &Path,
240    retired: &std::collections::HashSet<String>,
241) -> std::io::Result<Vec<ExportRecord>> {
242    let mut by_id: BTreeMap<String, ExportRecord> = BTreeMap::new();
243    for rec in read_jsonl(path).unwrap_or_default() {
244        if retired.contains(&rec.id) {
245            continue; // deliberately withdrawn — let it go
246        }
247        by_id.insert(rec.id.clone(), rec);
248    }
249    // Fresh wins on conflict: this clone's view of a record it CAN see is
250    // authoritative over whatever was committed earlier.
251    for rec in fresh {
252        by_id.insert(rec.id.clone(), rec.clone());
253    }
254    let mut out: Vec<ExportRecord> = by_id.into_values().collect();
255    out.sort_by(|a, b| a.id.cmp(&b.id));
256    Ok(out)
257}
258
259/// Write one JSONL file. Returns the byte count written.
260pub fn write_jsonl(path: &Path, records: &[ExportRecord]) -> std::io::Result<u64> {
261    if let Some(parent) = path.parent() {
262        std::fs::create_dir_all(parent)?;
263    }
264    let mut buf = String::new();
265    for rec in records {
266        let line = serde_json::to_string(rec)
267            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
268        buf.push_str(&line);
269        buf.push('\n');
270    }
271    let mut f = std::fs::File::create(path)?;
272    f.write_all(buf.as_bytes())?;
273    Ok(buf.len() as u64)
274}
275
276/// Read a conclusions JSONL file into records. A missing file is treated as
277/// empty (an add/add merge can hand us a nonexistent ancestor). Blank lines are
278/// skipped; a malformed line is a hard error so the driver falls back to a plain
279/// git conflict rather than silently dropping judgment.
280pub fn read_jsonl(path: &Path) -> std::io::Result<Vec<ExportRecord>> {
281    let content = match std::fs::read_to_string(path) {
282        Ok(c) => c,
283        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
284        Err(e) => return Err(e),
285    };
286    let mut out = Vec::new();
287    for line in content.lines() {
288        let line = line.trim();
289        if line.is_empty() {
290            continue;
291        }
292        let rec: ExportRecord = serde_json::from_str(line).map_err(|e| {
293            std::io::Error::new(
294                std::io::ErrorKind::InvalidData,
295                format!(
296                    "conclusions merge: unparseable line in {}: {e}",
297                    path.display()
298                ),
299            )
300        })?;
301        out.push(rec);
302    }
303    Ok(out)
304}
305
306/// Git merge driver for `.asd/conclusions/*.jsonl`.
307///
308/// Unions the records of `ours` and `theirs` keyed by `id` and rewrites `ours`
309/// in canonical export form (sorted by `id`, one compact record per line) —
310/// byte-identical to what [`write_jsonl`] would produce for that set, so the
311/// merge result is conflict-free and stable, and the next `export` is a no-op.
312///
313/// Union semantics (append-and-supersede sidecar): an entry survives iff it is
314/// present on either side, so entries superseded away on both sides stay gone,
315/// while an entry kept on one side is preserved. `base` is intentionally not
316/// consulted for inclusion; the ledger reconciles authoritatively on the next
317/// `import`, which is idempotent and keyed by `id`. On the (hash-keyed, so
318/// expected-impossible) event of one `id` carrying two different payloads, the
319/// lexicographically-greater serialization wins so the output is independent of
320/// which side git labeled `ours`.
321///
322/// Returns the number of records written.
323pub fn merge_jsonl(ours: &Path, theirs: &Path) -> std::io::Result<usize> {
324    let mut by_id: std::collections::BTreeMap<String, (String, ExportRecord)> =
325        std::collections::BTreeMap::new();
326    for path in [ours, theirs] {
327        for rec in read_jsonl(path)? {
328            let ser = serde_json::to_string(&rec)
329                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
330            match by_id.entry(rec.id.clone()) {
331                std::collections::btree_map::Entry::Vacant(v) => {
332                    v.insert((ser, rec));
333                }
334                std::collections::btree_map::Entry::Occupied(mut o) => {
335                    if ser > o.get().0 {
336                        o.insert((ser, rec));
337                    }
338                }
339            }
340        }
341    }
342    // BTreeMap iteration is ordered by `id`, matching `gather_buckets`' sort.
343    let records: Vec<ExportRecord> = by_id.into_values().map(|(_, rec)| rec).collect();
344    write_jsonl(ours, &records)?;
345    Ok(records.len())
346}
347
348/// One-shot: gather + write all six files under `out_dir`. Returns per-class
349/// (stem, entry_count, byte_count).
350///
351/// Plan K t-007: honors `.asd/config.toml` for sharding. Config is
352/// resolved from `out_dir`'s parent (the project root); missing
353/// config = default Class sharding. For per-Package mode, writes
354/// `<out_dir>/<stem>/<package-key>.jsonl` and returns one row per
355/// non-empty (class, package) pair.
356pub fn export_all(
357    engine: &Engine,
358    out_dir: &Path,
359) -> std::io::Result<Vec<(&'static str, usize, u64)>> {
360    std::fs::create_dir_all(out_dir)?;
361    // out_dir is typically `<root>/.asd/conclusions/`; the config
362    // lives at `<root>/.asd/config.toml`. Walk up two parents to
363    // find the project root.
364    let project_root = out_dir
365        .parent() // .asd/
366        .and_then(|p| p.parent()) // <root>/
367        .unwrap_or(out_dir);
368    let cfg = SidecarConfig::load_from_project(project_root);
369
370    match cfg.shard_by {
371        ShardBy::Class => export_class_layout(engine, out_dir),
372        ShardBy::Package => export_package_layout(engine, out_dir),
373    }
374}
375
376/// Default layout: one file per ConclusionClass under `out_dir`.
377fn export_class_layout(
378    engine: &Engine,
379    out_dir: &Path,
380) -> std::io::Result<Vec<(&'static str, usize, u64)>> {
381    let Gathered { buckets, retired } = gather(engine)?;
382    let mut out = Vec::with_capacity(6);
383    for class in ConclusionClass::all() {
384        let stem = class.filename_stem();
385        let path = out_dir.join(format!("{stem}.jsonl"));
386        let fresh = buckets.get(stem).cloned().unwrap_or_default();
387        // Additive: keep what is already committed unless this clone
388        // deliberately retired it. See `merge_preserving`.
389        let entries = merge_preserving(&fresh, &path, &retired)?;
390        let bytes = write_jsonl(&path, &entries)?;
391        out.push((stem, entries.len(), bytes));
392    }
393    Ok(out)
394}
395
396/// Per-Package layout: `<out_dir>/<stem>/<package-key>.jsonl`,
397/// one file per (class, package_dir) pair. Empty buckets produce
398/// no file. Plan K t-007.
399fn export_package_layout(
400    engine: &Engine,
401    out_dir: &Path,
402) -> std::io::Result<Vec<(&'static str, usize, u64)>> {
403    let Gathered { buckets, retired } = gather(engine)?;
404    let mut out = Vec::new();
405    for class in ConclusionClass::all() {
406        let stem = class.filename_stem();
407        let entries = buckets.get(stem).cloned().unwrap_or_default();
408        if entries.is_empty() {
409            continue;
410        }
411        // Re-bucket by package_dir(file). The same package always
412        // maps to the same shard filename → diffs and merges stay
413        // local to whichever package's entry changed.
414        let mut by_pkg: BTreeMap<String, Vec<ExportRecord>> = BTreeMap::new();
415        for rec in entries {
416            let pkg = package_dir(&rec.file);
417            by_pkg.entry(pkg).or_default().push(rec);
418        }
419        let class_dir = out_dir.join(stem);
420        std::fs::create_dir_all(&class_dir)?;
421        for (pkg, recs) in by_pkg {
422            let fname = format!("{}.jsonl", package_key_for_filename(&pkg));
423            let path = class_dir.join(fname);
424            // Same additive rule, per shard — the shard file is the unit on
425            // disk, so that is the unit to preserve against.
426            let merged = merge_preserving(&recs, &path, &retired)?;
427            let bytes = write_jsonl(&path, &merged)?;
428            out.push((stem, merged.len(), bytes));
429        }
430    }
431    Ok(out)
432}
433
434/// Directory portion of a file path — `src/pkg/foo.py` → `src/pkg`.
435/// Plan K t-007 mirror of the helper in commands/map.rs so the
436/// shard layout is computed from the same key.
437fn package_dir(file: &str) -> String {
438    match file.rsplit_once('/') {
439        Some((d, _)) => d.to_string(),
440        None => String::new(),
441    }
442}
443
444// -- Budget enforcement (Plan K t-008) -------------------------------------
445
446/// One shard file's size measurement against the per-shard budget.
447#[derive(Debug, Clone)]
448pub struct ShardSize {
449    /// Repo-relative path to the shard, e.g. `decisions.jsonl` or
450    /// `decisions/crates--core.jsonl`.
451    pub path: String,
452    pub bytes: u64,
453    /// True when `bytes > BudgetConfig::per_shard_bytes`.
454    pub over_per_shard: bool,
455}
456
457/// Result of `check_budget`. `ok` is true iff neither the total
458/// nor any individual shard exceeds its configured cap.
459#[derive(Debug, Clone)]
460pub struct BudgetReport {
461    pub ok: bool,
462    pub total_bytes: u64,
463    pub total_budget: u64,
464    pub per_shard_budget: u64,
465    pub shards: Vec<ShardSize>,
466    /// True when `total_bytes > total_budget`.
467    pub over_total: bool,
468}
469
470/// Plan K t-008: walk `out_dir`, sum sizes per shard file
471/// (including per-package subdirectory shards from t-007), and
472/// compare against the budget config. Pure read of the
473/// filesystem — does not re-export.
474pub fn check_budget(
475    out_dir: &Path,
476    budget: &crate::sidecar_config::BudgetConfig,
477) -> std::io::Result<BudgetReport> {
478    let mut shards: Vec<ShardSize> = Vec::new();
479    if out_dir.is_dir() {
480        collect_jsonl_sizes(out_dir, out_dir, &mut shards)?;
481    }
482    let mut total_bytes = 0u64;
483    for s in &mut shards {
484        s.over_per_shard = s.bytes > budget.per_shard_bytes;
485        total_bytes = total_bytes.saturating_add(s.bytes);
486    }
487    let over_total = total_bytes > budget.total_bytes;
488    let ok = !over_total && shards.iter().all(|s| !s.over_per_shard);
489    // Stable order for deterministic output.
490    shards.sort_by(|a, b| a.path.cmp(&b.path));
491    Ok(BudgetReport {
492        ok,
493        total_bytes,
494        total_budget: budget.total_bytes,
495        per_shard_budget: budget.per_shard_bytes,
496        shards,
497        over_total,
498    })
499}
500
501fn collect_jsonl_sizes(base: &Path, dir: &Path, out: &mut Vec<ShardSize>) -> std::io::Result<()> {
502    for entry in std::fs::read_dir(dir)? {
503        let entry = entry?;
504        let path = entry.path();
505        if path.is_dir() {
506            collect_jsonl_sizes(base, &path, out)?;
507        } else if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
508            let bytes = path.metadata().map(|m| m.len()).unwrap_or(0);
509            let rel = path
510                .strip_prefix(base)
511                .unwrap_or(&path)
512                .to_string_lossy()
513                .replace('\\', "/");
514            out.push(ShardSize {
515                path: rel,
516                bytes,
517                over_per_shard: false,
518            });
519        }
520    }
521    Ok(())
522}
523
524fn author_kind_str(k: AuthorKind) -> &'static str {
525    match k {
526        AuthorKind::Agent => "agent",
527        AuthorKind::Human => "human",
528    }
529}
530
531// -- Import (Plan B t-005) ---------------------------------------------------
532
533use crate::schema::{Author, Evidence, LedgerEntry};
534use chrono::{DateTime, Utc};
535
536/// Outcome of importing one JSONL file.
537#[derive(Debug, Clone, serde::Serialize)]
538pub struct ImportFileResult {
539    pub class: &'static str,
540    pub file: String,
541    /// Plan T t-007: total non-empty lines read from the file. Always
542    /// equals `imported + skipped_up_to_date + skipped_unknown_qname +
543    /// skipped_parse_error` — surfaced so callers can spot silent drops
544    /// without doing the arithmetic themselves.
545    pub read: usize,
546    pub imported: usize,
547    /// Records the store already holds at the same or a newer revision.
548    /// Not a drop: importing them would have replaced newer data (an entry
549    /// updated in place since the last export) with the committed copy.
550    pub skipped_up_to_date: usize,
551    pub skipped_unknown_qname: usize,
552    pub skipped_parse_error: usize,
553}
554
555/// One-shot: read every `<class>.jsonl` under `in_dir` and upsert each
556/// record into the local ledger keyed by entry_id. Idempotent — re-import
557/// of unchanged JSONL is a no-op at the bytes level (set_json overwrites
558/// with identical content).
559///
560/// Records whose `qname` is not in the current index are skipped with a
561/// counter so callers can warn. Use after `git pull` / on a fresh clone.
562///
563/// Plan K t-007: reads either layout transparently. For each class:
564///   1. If `<in_dir>/<stem>.jsonl` exists (class layout), import that.
565///   2. If `<in_dir>/<stem>/` directory exists (package layout),
566///      import every `*.jsonl` inside.
567/// Both can coexist during a layout migration; the importer handles
568/// the union without de-duplication beyond entry_id idempotency.
569pub fn import_all(
570    engine: &Engine,
571    in_dir: &Path,
572    agent_id: &str,
573) -> std::io::Result<Vec<ImportFileResult>> {
574    let mut out = Vec::new();
575    let mut stored = stored_entries(engine)?;
576    for class in ConclusionClass::all() {
577        let stem = class.filename_stem();
578        // Class-layout file (default).
579        let class_file = in_dir.join(format!("{stem}.jsonl"));
580        if class_file.is_file() {
581            out.push(import_one(
582                engine,
583                &mut stored,
584                &class_file,
585                stem,
586                agent_id,
587            )?);
588        }
589        // Package-layout directory (opt-in via .asd/config.toml).
590        // Read every *.jsonl in the per-class subdirectory.
591        let pkg_dir = in_dir.join(stem);
592        if pkg_dir.is_dir() {
593            let mut shard_paths: Vec<std::path::PathBuf> = std::fs::read_dir(&pkg_dir)?
594                .filter_map(|e| e.ok().map(|e| e.path()))
595                .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("jsonl"))
596                .collect();
597            // Sort for stable import order (helps test determinism).
598            shard_paths.sort();
599            for path in shard_paths {
600                out.push(import_one(engine, &mut stored, &path, stem, agent_id)?);
601            }
602        }
603        // If neither path exists, emit a zero-row result so callers
604        // see the class was considered (matches prior behavior).
605        if !class_file.is_file() && !pkg_dir.is_dir() {
606            out.push(ImportFileResult {
607                class: stem,
608                file: class_file.to_string_lossy().into_owned(),
609                read: 0,
610                imported: 0,
611                skipped_up_to_date: 0,
612                skipped_unknown_qname: 0,
613                skipped_parse_error: 0,
614            });
615        }
616    }
617    Ok(out)
618}
619
620fn import_one(
621    engine: &Engine,
622    stored: &mut std::collections::HashMap<String, LedgerEntry>,
623    path: &Path,
624    stem: &'static str,
625    agent_id: &str,
626) -> std::io::Result<ImportFileResult> {
627    let mut result = ImportFileResult {
628        class: stem,
629        file: path.display().to_string(),
630        read: 0,
631        imported: 0,
632        skipped_up_to_date: 0,
633        skipped_unknown_qname: 0,
634        skipped_parse_error: 0,
635    };
636    let text = match std::fs::read_to_string(path) {
637        Ok(t) => t,
638        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(result),
639        Err(e) => return Err(e),
640    };
641    let index = AsgIndexStore::from_engine(engine);
642    let ledger = AsgLedgerStore::from_engine(engine);
643    let ref_name = engine.ref_name.clone();
644
645    for line in text.lines() {
646        if line.trim().is_empty() {
647            continue;
648        }
649        result.read += 1;
650        let rec: ExportRecord = match serde_json::from_str(line) {
651            Ok(r) => r,
652            Err(_) => {
653                result.skipped_parse_error += 1;
654                continue;
655            }
656        };
657        let sym = match index.get_symbol_by_qname(&ref_name, &rec.qname) {
658            Ok(Some(s)) => s,
659            _ => {
660                result.skipped_unknown_qname += 1;
661                continue;
662            }
663        };
664        let entry = match record_to_entry(rec, &sym.symbol_id) {
665            Some(e) => e,
666            None => {
667                result.skipped_parse_error += 1;
668                continue;
669            }
670        };
671        // A committed record must never replace a newer copy in the store.
672        // The post-merge/post-checkout hook re-appended every committed
673        // record unconditionally, reverting each entry revised in place since
674        // the last export to the committed copy on every pull or branch
675        // switch. A tie goes to the store. Looking the entry up by id also
676        // stops a rebound entry from being resurrected at its old symbol.
677        if let Some(current) = stored.get(&entry.entry_id)
678            && revised_at(current) >= revised_at(&entry)
679        {
680            result.skipped_up_to_date += 1;
681            continue;
682        }
683        if ledger.append_entry(&ref_name, &entry, agent_id).is_ok() {
684            result.imported += 1;
685            stored.insert(entry.entry_id.clone(), entry);
686        } else {
687            result.skipped_parse_error += 1;
688        }
689    }
690    Ok(result)
691}
692
693/// When `entry` was last revised. Entries are revised in place two ways:
694/// rewritten under the same id with a fresh `created_at` (`asd think`'s
695/// deterministic ids), or tagged with the time of the change while
696/// `created_at` stays put (`approved-at:`, `rejected-at:` and `withdrawn-at:`
697/// from ratify, `orphaned-at:` from indexing).
698pub(crate) fn revised_at(entry: &LedgerEntry) -> DateTime<Utc> {
699    entry
700        .tags
701        .iter()
702        .filter_map(|tag| tag.split_once("-at:"))
703        .filter_map(|(_, at)| DateTime::parse_from_rfc3339(at).ok())
704        .map(|at| at.with_timezone(&Utc))
705        .fold(entry.created_at, DateTime::max)
706}
707
708/// Every stored ledger entry by id, wherever it lives — an entry rebound
709/// since the export sits under its new symbol. Read in one pass over the
710/// ledger tree: a lookup per record re-read the whole ledger map each time,
711/// ~100 s for 8,700 records. Where one id is stored twice, the latest
712/// revision stands.
713fn stored_entries(
714    engine: &Engine,
715) -> std::io::Result<std::collections::HashMap<String, LedgerEntry>> {
716    let mut stored: std::collections::HashMap<String, LedgerEntry> =
717        std::collections::HashMap::new();
718    let tree = match engine
719        .repo
720        .get_tree(&engine.ref_name, &crate::paths::ledger_root())
721    {
722        Ok(tree) => tree,
723        Err(agentstategraph::RepoError::Tree(agentstategraph::tree::TreeError::PathNotFound(
724            _,
725        ))) => return Ok(stored),
726        // Not an empty ledger: importing as if it were would revert every
727        // revision made since the last export.
728        Err(e) => return Err(std::io::Error::other(format!("read the ledger: {e}"))),
729    };
730    let entries = tree
731        .as_object()
732        .into_iter()
733        .flat_map(|by_symbol| by_symbol.values())
734        .filter_map(|per_symbol| per_symbol.as_object())
735        .flat_map(|by_id| by_id.values())
736        .filter_map(|v| serde_json::from_value::<LedgerEntry>(v.clone()).ok());
737    for entry in entries {
738        match stored.get(&entry.entry_id) {
739            Some(kept) if revised_at(kept) >= revised_at(&entry) => {}
740            _ => {
741                stored.insert(entry.entry_id.clone(), entry);
742            }
743        }
744    }
745    Ok(stored)
746}
747
748/// Rebuild a LedgerEntry from an ExportRecord. Returns None on unparseable
749/// kind, author, or timestamp — caller bumps skipped_parse_error.
750fn record_to_entry(rec: ExportRecord, symbol_id: &str) -> Option<LedgerEntry> {
751    let kind = parse_kind(&rec.kind)?;
752    let author = parse_author(&rec.author)?;
753    let created_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&rec.created_at)
754        .ok()?
755        .with_timezone(&Utc);
756    let evidence: Vec<Evidence> = rec
757        .evidence
758        .into_iter()
759        .filter_map(|v| serde_json::from_value(v).ok())
760        .collect();
761    Some(LedgerEntry {
762        entry_id: rec.id,
763        symbol_id: symbol_id.to_string(),
764        kind,
765        summary: rec.summary,
766        body: rec.body,
767        author,
768        confidence: rec.confidence,
769        evidence,
770        supersedes: rec.supersedes,
771        created_at,
772        tags: rec.tags,
773        matched_policy: None,
774        role: rec.role,
775        command: rec.command,
776    })
777}
778
779/// Parse the wire string back to a LedgerKind via serde — LedgerKind's
780/// `#[serde(rename_all = "snake_case")]` names are exactly what
781/// `LedgerKind::as_str()` emits on export, so this stays in lockstep
782/// with the enum automatically.
783///
784/// Plan T t-007: the previous hand-written match predated Plan G and
785/// silently rejected the four thinking kinds (hypothesis, mental_model,
786/// failed_attempt, open_question) — export wrote thinking.jsonl but
787/// import counted every row as a parse error, breaking the fresh-clone
788/// restore promise. Serde-derived parsing eliminates that drift class:
789/// any future LedgerKind variant round-trips without touching this file.
790fn parse_kind(s: &str) -> Option<LedgerKind> {
791    serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
792}
793
794fn parse_author(s: &str) -> Option<Author> {
795    let (kind_str, id) = s.split_once(':')?;
796    let kind = match kind_str {
797        "agent" => AuthorKind::Agent,
798        "human" => AuthorKind::Human,
799        _ => return None,
800    };
801    Some(Author {
802        kind,
803        id: id.to_string(),
804    })
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810    use crate::schema::SymbolKind;
811    use tempfile::tempdir;
812
813    #[test]
814    fn write_jsonl_is_byte_stable_across_runs() {
815        let tmp = tempdir().unwrap();
816        let path = tmp.path().join("decisions.jsonl");
817        let recs = vec![ExportRecord {
818            id: "led_1".into(),
819            kind: "decision".into(),
820            qname: "App.A".into(),
821            file: "a.rs".into(),
822            summary: "first".into(),
823            body: None,
824            confidence: None,
825            role: None,
826            command: None,
827            tags: vec![],
828            evidence: vec![],
829            supersedes: vec![],
830            author: "agent:c".into(),
831            created_at: "2026-05-19T20:00:00+00:00".into(),
832        }];
833        let n1 = write_jsonl(&path, &recs).unwrap();
834        let bytes1 = std::fs::read(&path).unwrap();
835        let n2 = write_jsonl(&path, &recs).unwrap();
836        let bytes2 = std::fs::read(&path).unwrap();
837        assert_eq!(n1, n2);
838        assert_eq!(bytes1, bytes2);
839    }
840
841    #[test]
842    fn optional_fields_are_skipped_in_serialization() {
843        let rec = ExportRecord {
844            id: "led_abc".into(),
845            kind: "decision".into(),
846            qname: "App.Foo.bar".into(),
847            file: "src/foo.rs".into(),
848            summary: "be careful".into(),
849            body: None,
850            confidence: None,
851            role: None,
852            command: None,
853            tags: vec![],
854            evidence: vec![],
855            supersedes: vec![],
856            author: "agent:claude".into(),
857            created_at: "2026-05-19T20:00:00+00:00".into(),
858        };
859        let line = serde_json::to_string(&rec).unwrap();
860        assert!(!line.contains("\"body\""));
861        assert!(!line.contains("\"role\""));
862        assert!(!line.contains("\"command\""));
863        assert!(!line.contains("\"tags\""));
864        assert!(line.contains("\"id\":\"led_abc\""));
865    }
866
867    #[test]
868    fn export_import_round_trips_an_entry() {
869        use crate::engine::Engine;
870        use crate::index::{AsgIndexStore, IndexStore};
871        use crate::ledger::{AsgLedgerStore, LedgerStore};
872        use crate::schema::{
873            Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
874        };
875
876        let engine = Engine::open_in_memory().unwrap();
877        let index = AsgIndexStore::from_engine(&engine);
878        let ledger = AsgLedgerStore::from_engine(&engine);
879
880        // Seed one symbol.
881        let sym = Symbol {
882            symbol_id: "sym_round_trip".into(),
883            symbol_fp: "fp_rt".into(),
884            qname: "App.Round.trip".into(),
885            language: "rust".into(),
886            kind: SymbolKind::Function,
887            file: "src/rt.rs".into(),
888            start: Position { line: 1, col: 0 },
889            end: Position { line: 10, col: 0 },
890            signature: None,
891            doc: None,
892        };
893        index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
894
895        // Seed one ledger entry exercising the new fields.
896        let mut entry = LedgerEntry::new(
897            &sym.symbol_id,
898            LedgerKind::FollowUp,
899            "diagnostics still need migration",
900            Author {
901                kind: AuthorKind::Agent,
902                id: "claude".into(),
903            },
904        );
905        entry.role = Some("diagnostic-test".into());
906        entry.command = Some("swift test --filter X".into());
907        entry.tags = vec!["ctx:plan:p".into()];
908        let original_id = entry.entry_id.clone();
909        ledger
910            .append_entry(&engine.ref_name, &entry, "test")
911            .unwrap();
912
913        // Export → import into a fresh engine that has the same symbol.
914        let tmp = tempdir().unwrap();
915        export_all(&engine, tmp.path()).unwrap();
916
917        let engine2 = Engine::open_in_memory().unwrap();
918        let index2 = AsgIndexStore::from_engine(&engine2);
919        index2.put_symbol(&engine2.ref_name, &sym, "test").unwrap();
920        let results = import_all(&engine2, tmp.path(), "test").unwrap();
921
922        let total_imported: usize = results.iter().map(|r| r.imported).sum();
923        assert_eq!(total_imported, 1);
924
925        let ledger2 = AsgLedgerStore::from_engine(&engine2);
926        let entries = ledger2
927            .list_entries(&engine2.ref_name, &sym.symbol_id)
928            .unwrap();
929        assert_eq!(entries.len(), 1);
930        let imported = &entries[0];
931        assert_eq!(imported.entry_id, original_id);
932        assert_eq!(imported.kind, LedgerKind::FollowUp);
933        assert_eq!(imported.role.as_deref(), Some("diagnostic-test"));
934        assert_eq!(imported.command.as_deref(), Some("swift test --filter X"));
935        assert_eq!(imported.tags, vec!["ctx:plan:p".to_string()]);
936    }
937
938    #[test]
939    fn import_is_idempotent_when_run_twice() {
940        use crate::engine::Engine;
941        use crate::index::{AsgIndexStore, IndexStore};
942        use crate::ledger::{AsgLedgerStore, LedgerStore};
943        use crate::schema::{
944            Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
945        };
946
947        let engine = Engine::open_in_memory().unwrap();
948        let index = AsgIndexStore::from_engine(&engine);
949        let ledger = AsgLedgerStore::from_engine(&engine);
950        let sym = Symbol {
951            symbol_id: "sym_idem".into(),
952            symbol_fp: "fp_idem".into(),
953            qname: "App.Idem".into(),
954            language: "rust".into(),
955            kind: SymbolKind::Function,
956            file: "src/idem.rs".into(),
957            start: Position { line: 1, col: 0 },
958            end: Position { line: 2, col: 0 },
959            signature: None,
960            doc: None,
961        };
962        index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
963        let entry = LedgerEntry::new(
964            &sym.symbol_id,
965            LedgerKind::Decision,
966            "x",
967            Author {
968                kind: AuthorKind::Agent,
969                id: "c".into(),
970            },
971        );
972        ledger
973            .append_entry(&engine.ref_name, &entry, "test")
974            .unwrap();
975
976        let tmp = tempdir().unwrap();
977        export_all(&engine, tmp.path()).unwrap();
978
979        let engine2 = Engine::open_in_memory().unwrap();
980        AsgIndexStore::from_engine(&engine2)
981            .put_symbol(&engine2.ref_name, &sym, "test")
982            .unwrap();
983        import_all(&engine2, tmp.path(), "test").unwrap();
984        import_all(&engine2, tmp.path(), "test").unwrap();
985
986        let entries = AsgLedgerStore::from_engine(&engine2)
987            .list_entries(&engine2.ref_name, &sym.symbol_id)
988            .unwrap();
989        assert_eq!(entries.len(), 1, "second import must not duplicate");
990    }
991
992    // ---- a committed record never replaces a newer stored revision --------
993
994    fn revision_symbol() -> crate::schema::Symbol {
995        crate::schema::Symbol {
996            symbol_id: "sym_rev".into(),
997            symbol_fp: "fp_rev".into(),
998            qname: "App.Rates.fetch".into(),
999            language: "python".into(),
1000            kind: SymbolKind::Function,
1001            file: "src/rates.py".into(),
1002            start: crate::schema::Position { line: 1, col: 0 },
1003            end: crate::schema::Position { line: 2, col: 0 },
1004            signature: None,
1005            doc: None,
1006        }
1007    }
1008
1009    /// A store holding `revision_symbol` and `entry`.
1010    fn store_with(entry: &LedgerEntry) -> Engine {
1011        let engine = Engine::open_in_memory().unwrap();
1012        AsgIndexStore::from_engine(&engine)
1013            .put_symbol(&engine.ref_name, &revision_symbol(), "test")
1014            .unwrap();
1015        AsgLedgerStore::from_engine(&engine)
1016            .append_entry(&engine.ref_name, entry, "test")
1017            .unwrap();
1018        engine
1019    }
1020
1021    fn decision() -> LedgerEntry {
1022        LedgerEntry::new(
1023            "sym_rev",
1024            LedgerKind::Decision,
1025            "retry rates once",
1026            Author {
1027                kind: AuthorKind::Agent,
1028                id: "c".into(),
1029            },
1030        )
1031    }
1032
1033    fn stored(engine: &Engine, entry_id: &str) -> LedgerEntry {
1034        AsgLedgerStore::from_engine(engine)
1035            .list_entries(&engine.ref_name, "sym_rev")
1036            .unwrap()
1037            .into_iter()
1038            .find(|e| e.entry_id == entry_id)
1039            .unwrap()
1040    }
1041
1042    fn approve(entry: &mut LedgerEntry, at: DateTime<Utc>) {
1043        // What ratify's approve does: tags in place, `created_at` untouched.
1044        entry.tags.push("approved".into());
1045        entry.tags.push(format!(
1046            "approved-at:{}",
1047            at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1048        ));
1049    }
1050
1051    #[test]
1052    fn import_keeps_an_entry_approved_in_place_since_the_export() {
1053        let entry = decision();
1054        let engine = store_with(&entry);
1055        let tmp = tempdir().unwrap();
1056        export_all(&engine, tmp.path()).unwrap(); // committed: not approved
1057
1058        let mut approved = entry.clone();
1059        approve(&mut approved, entry.created_at + chrono::Duration::hours(1));
1060        AsgLedgerStore::from_engine(&engine)
1061            .append_entry(&engine.ref_name, &approved, "test")
1062            .unwrap();
1063
1064        let results = import_all(&engine, tmp.path(), "hook").unwrap();
1065        assert_eq!(
1066            results.iter().map(|r| r.skipped_up_to_date).sum::<usize>(),
1067            1
1068        );
1069        assert_eq!(results.iter().map(|r| r.imported).sum::<usize>(), 0);
1070        assert!(
1071            stored(&engine, &entry.entry_id)
1072                .tags
1073                .contains(&"approved".to_string()),
1074            "import reverted an in-place approval"
1075        );
1076    }
1077
1078    #[test]
1079    fn import_applies_a_teammates_in_place_approval() {
1080        let entry = decision();
1081        let mine = store_with(&entry);
1082
1083        let mut approved = entry.clone();
1084        approve(&mut approved, entry.created_at + chrono::Duration::hours(1));
1085        let theirs = store_with(&approved);
1086        let tmp = tempdir().unwrap();
1087        export_all(&theirs, tmp.path()).unwrap();
1088
1089        let results = import_all(&mine, tmp.path(), "hook").unwrap();
1090        assert_eq!(results.iter().map(|r| r.imported).sum::<usize>(), 1);
1091        assert!(
1092            stored(&mine, &entry.entry_id)
1093                .tags
1094                .contains(&"approved".to_string()),
1095            "a teammate's approval did not import"
1096        );
1097    }
1098
1099    #[test]
1100    fn import_applies_a_newer_rewrite_under_the_same_id() {
1101        let entry = decision();
1102        let mine = store_with(&entry);
1103
1104        // `asd think` re-run elsewhere: same id, fresh `created_at`.
1105        let mut rewritten = entry.clone();
1106        rewritten.summary = "retry rates twice".into();
1107        rewritten.created_at = entry.created_at + chrono::Duration::hours(1);
1108        let theirs = store_with(&rewritten);
1109        let tmp = tempdir().unwrap();
1110        export_all(&theirs, tmp.path()).unwrap();
1111
1112        import_all(&mine, tmp.path(), "hook").unwrap();
1113        assert_eq!(stored(&mine, &entry.entry_id).summary, "retry rates twice");
1114
1115        // And the reverse: the older committed copy does not come back.
1116        let tmp_old = tempdir().unwrap();
1117        export_all(&store_with(&entry), tmp_old.path()).unwrap();
1118        import_all(&mine, tmp_old.path(), "hook").unwrap();
1119        assert_eq!(stored(&mine, &entry.entry_id).summary, "retry rates twice");
1120    }
1121
1122    // ---- Plan K t-001: sort-on-write byte-stability + conflict-resistance --
1123
1124    /// Build a fresh engine with N decision entries inserted in the
1125    /// given order. Returns the (id, summary) pairs in the order they
1126    /// were inserted so the caller can reason about the input.
1127    fn engine_with_decisions_in_order(
1128        ids_and_summaries: &[(&str, &str)],
1129    ) -> (Engine, Vec<(String, String)>) {
1130        let engine = Engine::open_in_memory().unwrap();
1131        let sym = crate::schema::Symbol {
1132            symbol_id: "sym_order_test".into(),
1133            symbol_fp: "fp".into(),
1134            qname: "pkg.target".into(),
1135            language: "python".into(),
1136            kind: SymbolKind::Function,
1137            file: "src/target.py".into(),
1138            start: crate::schema::Position { line: 1, col: 0 },
1139            end: crate::schema::Position { line: 5, col: 0 },
1140            signature: None,
1141            doc: None,
1142        };
1143        AsgIndexStore::from_engine(&engine)
1144            .put_symbol(&engine.ref_name, &sym, "test")
1145            .unwrap();
1146        let ledger = AsgLedgerStore::from_engine(&engine);
1147        let mut inserted = Vec::new();
1148        for (id, summary) in ids_and_summaries {
1149            let mut entry = LedgerEntry::new(
1150                &sym.symbol_id,
1151                LedgerKind::Decision,
1152                *summary,
1153                Author {
1154                    kind: AuthorKind::Agent,
1155                    id: "t".into(),
1156                },
1157            );
1158            entry.entry_id = (*id).to_string();
1159            ledger.append_entry(&engine.ref_name, &entry, "t").unwrap();
1160            inserted.push(((*id).to_string(), (*summary).to_string()));
1161        }
1162        (engine, inserted)
1163    }
1164
1165    fn sha_of_file(p: &Path) -> [u8; 32] {
1166        let bytes = std::fs::read(p).expect("read jsonl");
1167        *blake3::hash(&bytes).as_bytes()
1168    }
1169
1170    #[test]
1171    fn export_is_byte_identical_across_repeated_runs() {
1172        // Plan K t-001 byte-stability claim, scoped correctly: SAME
1173        // engine, two consecutive exports → identical bytes. (Across
1174        // independent engines the per-entry `created_at` timestamp
1175        // differs at append time, so byte-equality across engines is
1176        // expected to fail and not what id-sort can fix.)
1177        let entries = [
1178            ("led_aaa", "first decision"),
1179            ("led_zzz", "third decision"),
1180            ("led_mmm", "second decision"),
1181        ];
1182        let (engine, _) = engine_with_decisions_in_order(&entries);
1183
1184        let tmp1 = tempfile::tempdir().unwrap();
1185        let tmp2 = tempfile::tempdir().unwrap();
1186        export_all(&engine, tmp1.path()).unwrap();
1187        export_all(&engine, tmp2.path()).unwrap();
1188
1189        assert_eq!(
1190            sha_of_file(&tmp1.path().join("decisions.jsonl")),
1191            sha_of_file(&tmp2.path().join("decisions.jsonl")),
1192            "two consecutive exports from the same engine must hash-equal"
1193        );
1194    }
1195
1196    #[test]
1197    fn export_byte_output_is_deterministic_under_id_sort() {
1198        // Stronger guarantee: the id-sort sorts the SAME entries into
1199        // the SAME order regardless of insertion order. Verify by
1200        // building two engines with identical (id, summary, …) inputs
1201        // but inserted in different orders, stripping the per-entry
1202        // created_at before comparing, then asserting both yield the
1203        // same line sequence.
1204        let order_a = [
1205            ("led_aaa", "first decision"),
1206            ("led_zzz", "third decision"),
1207            ("led_mmm", "second decision"),
1208        ];
1209        let order_b = [
1210            ("led_zzz", "third decision"),
1211            ("led_mmm", "second decision"),
1212            ("led_aaa", "first decision"),
1213        ];
1214
1215        let (engine_a, _) = engine_with_decisions_in_order(&order_a);
1216        let (engine_b, _) = engine_with_decisions_in_order(&order_b);
1217
1218        let tmp_a = tempfile::tempdir().unwrap();
1219        let tmp_b = tempfile::tempdir().unwrap();
1220        export_all(&engine_a, tmp_a.path()).unwrap();
1221        export_all(&engine_b, tmp_b.path()).unwrap();
1222
1223        // Read line-by-line and pull out (id, summary) — drop the
1224        // engine-specific created_at — and assert the SEQUENCE
1225        // matches between the two engines.
1226        let extract = |path: &Path| -> Vec<(String, String)> {
1227            std::fs::read_to_string(path)
1228                .unwrap()
1229                .lines()
1230                .map(|l| {
1231                    let v: serde_json::Value = serde_json::from_str(l).unwrap();
1232                    (
1233                        v["id"].as_str().unwrap_or("").to_string(),
1234                        v["summary"].as_str().unwrap_or("").to_string(),
1235                    )
1236                })
1237                .collect()
1238        };
1239        let seq_a = extract(&tmp_a.path().join("decisions.jsonl"));
1240        let seq_b = extract(&tmp_b.path().join("decisions.jsonl"));
1241        assert_eq!(
1242            seq_a, seq_b,
1243            "id-sort must yield identical (id, summary) sequence regardless of insertion order"
1244        );
1245        // And the sequence must actually be id-sorted.
1246        let ids: Vec<&str> = seq_a.iter().map(|(id, _)| id.as_str()).collect();
1247        assert_eq!(
1248            ids,
1249            vec!["led_aaa", "led_mmm", "led_zzz"],
1250            "entries must come out in id-sorted order"
1251        );
1252    }
1253
1254    /// Seed an engine with N indexed symbols spread across two
1255    /// packages, then return the engine. Used by Plan K t-007
1256    /// sharding tests.
1257    fn engine_with_multi_package_decisions() -> Engine {
1258        let engine = Engine::open_in_memory().unwrap();
1259        let mk_sym = |sid: &str, qname: &str, file: &str| crate::schema::Symbol {
1260            symbol_id: sid.into(),
1261            symbol_fp: "fp".into(),
1262            qname: qname.into(),
1263            language: "python".into(),
1264            kind: SymbolKind::Function,
1265            file: file.into(),
1266            start: crate::schema::Position { line: 1, col: 0 },
1267            end: crate::schema::Position { line: 5, col: 0 },
1268            signature: None,
1269            doc: None,
1270        };
1271        let index = AsgIndexStore::from_engine(&engine);
1272        let sym_a = mk_sym("sym_a", "pkg.alpha.fn_a", "src/alpha/mod.py");
1273        let sym_b = mk_sym("sym_b", "pkg.beta.fn_b", "src/beta/mod.py");
1274        index.put_symbol(&engine.ref_name, &sym_a, "t").unwrap();
1275        index.put_symbol(&engine.ref_name, &sym_b, "t").unwrap();
1276
1277        let append = |sid: &str, eid: &str, summary: &str| {
1278            let mut entry = LedgerEntry::new(
1279                sid,
1280                LedgerKind::Decision,
1281                summary,
1282                Author {
1283                    kind: AuthorKind::Agent,
1284                    id: "t".into(),
1285                },
1286            );
1287            entry.entry_id = eid.into();
1288            AsgLedgerStore::from_engine(&engine)
1289                .append_entry(&engine.ref_name, &entry, "t")
1290                .unwrap();
1291        };
1292        append("sym_a", "led_a1", "alpha decision 1");
1293        append("sym_a", "led_a2", "alpha decision 2");
1294        append("sym_b", "led_b1", "beta decision 1");
1295        engine
1296    }
1297
1298    // ---- Plan K t-008: budget enforcement ------------------------------
1299
1300    #[test]
1301    fn check_budget_reports_ok_when_well_under_limits() {
1302        use crate::sidecar_config::BudgetConfig;
1303        let tmp = tempdir().unwrap();
1304        // Write one small JSONL file.
1305        std::fs::write(tmp.path().join("decisions.jsonl"), b"{}\n{}\n").unwrap();
1306        let budget = BudgetConfig {
1307            total_bytes: 1024,
1308            per_shard_bytes: 1024,
1309        };
1310        let r = check_budget(tmp.path(), &budget).unwrap();
1311        assert!(r.ok);
1312        assert!(!r.over_total);
1313        assert_eq!(r.shards.len(), 1);
1314        assert!(!r.shards[0].over_per_shard);
1315    }
1316
1317    #[test]
1318    fn check_budget_flags_over_total() {
1319        use crate::sidecar_config::BudgetConfig;
1320        let tmp = tempdir().unwrap();
1321        let blob = vec![b'x'; 600];
1322        std::fs::write(tmp.path().join("a.jsonl"), &blob).unwrap();
1323        std::fs::write(tmp.path().join("b.jsonl"), &blob).unwrap();
1324        let budget = BudgetConfig {
1325            total_bytes: 1000, // 1200 actual > 1000 budget
1326            per_shard_bytes: 1024,
1327        };
1328        let r = check_budget(tmp.path(), &budget).unwrap();
1329        assert!(!r.ok);
1330        assert!(r.over_total);
1331        // Neither individual shard violates per-shard.
1332        assert!(r.shards.iter().all(|s| !s.over_per_shard));
1333    }
1334
1335    #[test]
1336    fn check_budget_flags_over_per_shard() {
1337        use crate::sidecar_config::BudgetConfig;
1338        let tmp = tempdir().unwrap();
1339        let big = vec![b'x'; 500];
1340        std::fs::write(tmp.path().join("big.jsonl"), &big).unwrap();
1341        let budget = BudgetConfig {
1342            total_bytes: 10_000,
1343            per_shard_bytes: 200, // 500 actual > 200 per-shard
1344        };
1345        let r = check_budget(tmp.path(), &budget).unwrap();
1346        assert!(!r.ok);
1347        assert!(!r.over_total);
1348        assert!(r.shards[0].over_per_shard);
1349    }
1350
1351    #[test]
1352    fn check_budget_walks_into_package_subdirs() {
1353        // Plan K t-007 + t-008: when shard_by = "package" produces a
1354        // subdirectory layout, the budget walker must descend into
1355        // it. Sets up a per-class subdir + a file inside, asserts
1356        // the file is counted.
1357        use crate::sidecar_config::BudgetConfig;
1358        let tmp = tempdir().unwrap();
1359        std::fs::create_dir_all(tmp.path().join("decisions")).unwrap();
1360        std::fs::write(tmp.path().join("decisions/crates--core.jsonl"), b"row\n").unwrap();
1361        let r = check_budget(
1362            tmp.path(),
1363            &BudgetConfig {
1364                total_bytes: 1024,
1365                per_shard_bytes: 1024,
1366            },
1367        )
1368        .unwrap();
1369        assert_eq!(r.shards.len(), 1);
1370        assert!(
1371            r.shards[0].path.contains("decisions/crates--core.jsonl")
1372                || r.shards[0].path.contains("decisions\\crates--core.jsonl"),
1373            "shard path must include the subdir; got {:?}",
1374            r.shards[0].path
1375        );
1376    }
1377
1378    #[test]
1379    fn check_budget_handles_missing_directory_gracefully() {
1380        use crate::sidecar_config::BudgetConfig;
1381        let tmp = tempdir().unwrap();
1382        // tmp/conclusions doesn't exist.
1383        let r = check_budget(&tmp.path().join("conclusions"), &BudgetConfig::default()).unwrap();
1384        assert!(r.ok, "empty/missing dir is trivially under budget");
1385        assert_eq!(r.total_bytes, 0);
1386        assert!(r.shards.is_empty());
1387    }
1388
1389    #[test]
1390    fn package_layout_writes_per_package_shards() {
1391        // Plan K t-007 acceptance: with `shard_by = "package"`,
1392        // `export_all` writes `<out_dir>/<stem>/<pkg-key>.jsonl`
1393        // rather than `<out_dir>/<stem>.jsonl`.
1394        let tmp = tempdir().unwrap();
1395        let project_root = tmp.path();
1396        std::fs::create_dir_all(project_root.join(".asd")).unwrap();
1397        std::fs::write(
1398            project_root.join(".asd/config.toml"),
1399            "[sidecar]\nshard_by = \"package\"\n",
1400        )
1401        .unwrap();
1402
1403        let engine = engine_with_multi_package_decisions();
1404        let conclusions_dir = project_root.join(".asd/conclusions");
1405        export_all(&engine, &conclusions_dir).unwrap();
1406
1407        // Default layout files must NOT exist.
1408        assert!(
1409            !conclusions_dir.join("decisions.jsonl").is_file(),
1410            "package layout must not write a flat decisions.jsonl"
1411        );
1412        // Per-package files MUST exist.
1413        let alpha = conclusions_dir.join("decisions/src--alpha.jsonl");
1414        let beta = conclusions_dir.join("decisions/src--beta.jsonl");
1415        assert!(alpha.is_file(), "expected per-package shard at {alpha:?}");
1416        assert!(beta.is_file(), "expected per-package shard at {beta:?}");
1417
1418        let alpha_body = std::fs::read_to_string(&alpha).unwrap();
1419        assert!(alpha_body.contains("led_a1") && alpha_body.contains("led_a2"));
1420        assert!(
1421            !alpha_body.contains("led_b1"),
1422            "beta entry must not leak into alpha shard; got:\n{alpha_body}"
1423        );
1424    }
1425
1426    #[test]
1427    fn class_layout_is_default_without_config() {
1428        // No .asd/config.toml → default Class sharding, single flat
1429        // file per class. Locks the backward-compat promise.
1430        let tmp = tempdir().unwrap();
1431        let project_root = tmp.path();
1432        // intentionally no config.toml
1433
1434        let engine = engine_with_multi_package_decisions();
1435        let conclusions_dir = project_root.join(".asd/conclusions");
1436        export_all(&engine, &conclusions_dir).unwrap();
1437
1438        let flat = conclusions_dir.join("decisions.jsonl");
1439        assert!(
1440            flat.is_file(),
1441            "default layout must write flat decisions.jsonl"
1442        );
1443        let body = std::fs::read_to_string(&flat).unwrap();
1444        for id in ["led_a1", "led_a2", "led_b1"] {
1445            assert!(
1446                body.contains(id),
1447                "flat file must contain {id}; got:\n{body}"
1448            );
1449        }
1450        // The per-package subdir must NOT have been created.
1451        assert!(
1452            !conclusions_dir.join("decisions").is_dir(),
1453            "default layout must not create per-class subdirectory"
1454        );
1455    }
1456
1457    #[test]
1458    fn import_reads_package_layout_transparently() {
1459        // Plan K t-007: round-trip through package layout — export
1460        // with sharding on, import into a fresh engine, verify all
1461        // entries land in the ledger.
1462        let tmp = tempdir().unwrap();
1463        let project_root = tmp.path();
1464        std::fs::create_dir_all(project_root.join(".asd")).unwrap();
1465        std::fs::write(
1466            project_root.join(".asd/config.toml"),
1467            "[sidecar]\nshard_by = \"package\"\n",
1468        )
1469        .unwrap();
1470
1471        let engine = engine_with_multi_package_decisions();
1472        let conclusions_dir = project_root.join(".asd/conclusions");
1473        export_all(&engine, &conclusions_dir).unwrap();
1474
1475        // Fresh engine; replay the same symbols so import has qnames
1476        // to match against.
1477        let engine2 = Engine::open_in_memory().unwrap();
1478        let index2 = AsgIndexStore::from_engine(&engine2);
1479        for (sid, qn, file) in [
1480            ("sym_a", "pkg.alpha.fn_a", "src/alpha/mod.py"),
1481            ("sym_b", "pkg.beta.fn_b", "src/beta/mod.py"),
1482        ] {
1483            let sym = crate::schema::Symbol {
1484                symbol_id: sid.into(),
1485                symbol_fp: "fp".into(),
1486                qname: qn.into(),
1487                language: "python".into(),
1488                kind: SymbolKind::Function,
1489                file: file.into(),
1490                start: crate::schema::Position { line: 1, col: 0 },
1491                end: crate::schema::Position { line: 5, col: 0 },
1492                signature: None,
1493                doc: None,
1494            };
1495            index2.put_symbol(&engine2.ref_name, &sym, "t").unwrap();
1496        }
1497        let results = import_all(&engine2, &conclusions_dir, "t").unwrap();
1498        let total: usize = results.iter().map(|r| r.imported).sum();
1499        assert_eq!(
1500            total, 3,
1501            "all 3 entries (2 alpha + 1 beta) must import; got results = {results:?}"
1502        );
1503        // Confirm by reading the ledger directly.
1504        let ledger2 = AsgLedgerStore::from_engine(&engine2);
1505        let a = ledger2.list_entries(&engine2.ref_name, "sym_a").unwrap();
1506        let b = ledger2.list_entries(&engine2.ref_name, "sym_b").unwrap();
1507        assert_eq!(a.len(), 2, "sym_a must have 2 imported entries");
1508        assert_eq!(b.len(), 1, "sym_b must have 1 imported entry");
1509    }
1510
1511    /// Append a single thinking-class entry of a given kind to the
1512    /// given engine's `pkg.target` symbol. Used by the Plan K t-003
1513    /// confidence-floor tests.
1514    fn append_thinking_entry(
1515        engine: &Engine,
1516        kind: LedgerKind,
1517        id: &str,
1518        summary: &str,
1519        confidence: Option<f64>,
1520    ) {
1521        let mut entry = LedgerEntry::new(
1522            "sym_order_test",
1523            kind,
1524            summary,
1525            Author {
1526                kind: AuthorKind::Agent,
1527                id: "t".into(),
1528            },
1529        );
1530        entry.entry_id = id.to_string();
1531        entry.confidence = confidence;
1532        AsgLedgerStore::from_engine(engine)
1533            .append_entry(&engine.ref_name, &entry, "t")
1534            .unwrap();
1535    }
1536
1537    #[test]
1538    fn export_drops_low_confidence_hypotheses() {
1539        // Plan K t-003 acceptance: a Hypothesis below
1540        // DEFAULT_CONFIDENCE_FLOOR (0.3) must NOT appear in the
1541        // exported sidecar, even though it's in the ledger.
1542        let (engine, _) = engine_with_decisions_in_order(&[]);
1543        // High-confidence: should ship.
1544        append_thinking_entry(
1545            &engine,
1546            LedgerKind::Hypothesis,
1547            "led_strong",
1548            "well-supported hypothesis",
1549            Some(0.7),
1550        );
1551        // Low-confidence: must be filtered out.
1552        append_thinking_entry(
1553            &engine,
1554            LedgerKind::Hypothesis,
1555            "led_weak",
1556            "speculative guess",
1557            Some(0.1),
1558        );
1559        // Hypothesis with no confidence: treated as 0.0 → dropped.
1560        append_thinking_entry(
1561            &engine,
1562            LedgerKind::Hypothesis,
1563            "led_unset",
1564            "no confidence assigned",
1565            None,
1566        );
1567
1568        let tmp = tempfile::tempdir().unwrap();
1569        export_all(&engine, tmp.path()).unwrap();
1570        let body = std::fs::read_to_string(tmp.path().join("thinking.jsonl")).unwrap();
1571
1572        assert!(
1573            body.contains("led_strong"),
1574            "high-confidence hypothesis must ship; got:\n{body}"
1575        );
1576        assert!(
1577            !body.contains("led_weak"),
1578            "low-confidence hypothesis must NOT ship; got:\n{body}"
1579        );
1580        assert!(
1581            !body.contains("led_unset"),
1582            "hypothesis with no confidence must NOT ship; got:\n{body}"
1583        );
1584    }
1585
1586    #[test]
1587    fn confidence_floor_does_not_affect_non_hypothesis_kinds() {
1588        // Plan K t-003: the filter is Hypothesis-only. MentalModels,
1589        // FailedAttempts, and OpenQuestions don't carry a confidence
1590        // semantic in the same way — they're recorded judgment
1591        // regardless of certainty. Verify they ship even with
1592        // confidence below the floor (or unset).
1593        let (engine, _) = engine_with_decisions_in_order(&[]);
1594        append_thinking_entry(
1595            &engine,
1596            LedgerKind::MentalModel,
1597            "led_mm_low",
1598            "mental model with low conf marker",
1599            Some(0.1),
1600        );
1601        append_thinking_entry(
1602            &engine,
1603            LedgerKind::OpenQuestion,
1604            "led_oq_unset",
1605            "what does this mean?",
1606            None,
1607        );
1608        append_thinking_entry(
1609            &engine,
1610            LedgerKind::FailedAttempt,
1611            "led_fa_low",
1612            "tried X, failed because Y",
1613            Some(0.1),
1614        );
1615
1616        let tmp = tempfile::tempdir().unwrap();
1617        export_all(&engine, tmp.path()).unwrap();
1618        let body = std::fs::read_to_string(tmp.path().join("thinking.jsonl")).unwrap();
1619
1620        for must_appear in ["led_mm_low", "led_oq_unset", "led_fa_low"] {
1621            assert!(
1622                body.contains(must_appear),
1623                "non-Hypothesis kind {must_appear} must ship regardless of confidence; got:\n{body}"
1624            );
1625        }
1626    }
1627
1628    #[test]
1629    fn confidence_floor_does_not_affect_decisions() {
1630        // A Decision is a recorded choice; even "I'm 10% sure about
1631        // this" is still a decision worth committing. The filter
1632        // must only gate Hypothesis.
1633        let (engine, _) = engine_with_decisions_in_order(&[]);
1634        append_thinking_entry(
1635            &engine,
1636            LedgerKind::Decision,
1637            "led_dec_low",
1638            "decided X with low certainty",
1639            Some(0.1),
1640        );
1641
1642        let tmp = tempfile::tempdir().unwrap();
1643        export_all(&engine, tmp.path()).unwrap();
1644        let body = std::fs::read_to_string(tmp.path().join("decisions.jsonl")).unwrap();
1645        assert!(
1646            body.contains("led_dec_low"),
1647            "low-confidence Decision must still ship; got:\n{body}"
1648        );
1649    }
1650
1651    #[test]
1652    fn exported_entries_are_self_describing() {
1653        // Plan K t-004: every emitted entry must carry qname, kind,
1654        // and summary inline (no symbol_id-only references). An agent
1655        // reading raw .asd/conclusions/*.jsonl without a hydrated
1656        // index must still be able to grep for a qname and find
1657        // every relevant entry.
1658        let entries = [("led_target_1", "first decision about pkg.target")];
1659        let (engine, _) = engine_with_decisions_in_order(&entries);
1660        let tmp = tempfile::tempdir().unwrap();
1661        export_all(&engine, tmp.path()).unwrap();
1662
1663        let body = std::fs::read_to_string(tmp.path().join("decisions.jsonl")).unwrap();
1664        // Grep-by-qname must hit. (The grep test mirrors what an
1665        // agent reading the file cold would do.)
1666        assert!(
1667            body.contains("pkg.target"),
1668            "qname must appear in serialized line for grep-by-qname; got: {body}"
1669        );
1670
1671        // Structural check: parse the line and confirm the
1672        // self-describing fields are present and non-empty.
1673        let v: serde_json::Value =
1674            serde_json::from_str(body.lines().next().expect("one line")).unwrap();
1675        for field in ["id", "kind", "qname", "summary"] {
1676            let s = v[field].as_str().unwrap_or("");
1677            assert!(
1678                !s.is_empty(),
1679                "field `{field}` must be present and non-empty for self-describing entries; got {v}"
1680            );
1681        }
1682        // No symbol_id leak — entries reference symbols by qname
1683        // (human-readable) only.
1684        assert!(
1685            v.get("symbol_id").is_none(),
1686            "ExportRecord must not leak opaque symbol_id; got {v}"
1687        );
1688    }
1689
1690    #[test]
1691    fn export_sorts_by_id_not_by_created_at() {
1692        // Conflict-resistance check: with id-sort, two same-day
1693        // entries with very different ids end up far apart in the
1694        // file. Verify by inserting two entries and checking the
1695        // file lines them up in id order, not insertion order.
1696        let entries = [
1697            ("led_zzz", "zzz summary inserted first"),
1698            ("led_aaa", "aaa summary inserted second"),
1699        ];
1700        let (engine, _) = engine_with_decisions_in_order(&entries);
1701        let tmp = tempfile::tempdir().unwrap();
1702        export_all(&engine, tmp.path()).unwrap();
1703
1704        let body = std::fs::read_to_string(tmp.path().join("decisions.jsonl")).unwrap();
1705        let lines: Vec<&str> = body.lines().collect();
1706        assert_eq!(lines.len(), 2);
1707        // led_aaa < led_zzz lexicographically; aaa must come first
1708        // even though it was inserted second.
1709        assert!(
1710            lines[0].contains("led_aaa"),
1711            "id-sort: led_aaa must precede led_zzz; got first line = {}",
1712            lines[0]
1713        );
1714        assert!(
1715            lines[1].contains("led_zzz"),
1716            "id-sort: led_zzz must follow led_aaa; got second line = {}",
1717            lines[1]
1718        );
1719    }
1720
1721    // ---- Plan T t-007: lossless round-trip incl. thinking + confidence --
1722
1723    #[test]
1724    fn parse_kind_accepts_every_ledger_kind_string() {
1725        // The old hand-written parse_kind predated Plan G and silently
1726        // rejected the four thinking kinds. Lock the invariant that
1727        // every LedgerKind round-trips as_str → parse_kind.
1728        let all = [
1729            LedgerKind::Decision,
1730            LedgerKind::Assumption,
1731            LedgerKind::Constraint,
1732            LedgerKind::Rationale,
1733            LedgerKind::Hazard,
1734            LedgerKind::Tradeoff,
1735            LedgerKind::Invariant,
1736            LedgerKind::Ownership,
1737            LedgerKind::Proof,
1738            LedgerKind::ValidationScenario,
1739            LedgerKind::KnownBug,
1740            LedgerKind::Concept,
1741            LedgerKind::Mapping,
1742            LedgerKind::FollowUp,
1743            LedgerKind::Hypothesis,
1744            LedgerKind::MentalModel,
1745            LedgerKind::FailedAttempt,
1746            LedgerKind::OpenQuestion,
1747        ];
1748        for kind in all {
1749            assert_eq!(
1750                parse_kind(kind.as_str()),
1751                Some(kind),
1752                "parse_kind must accept `{}`",
1753                kind.as_str()
1754            );
1755        }
1756        assert_eq!(parse_kind("no_such_kind"), None);
1757    }
1758
1759    #[test]
1760    fn export_wipe_import_round_trips_all_classes_losslessly() {
1761        // Plan T t-007 acceptance: export → wipe ledger (fresh engine)
1762        // → import → field-level diff. Covers all four thinking kinds
1763        // with their body payloads (mental-model symbols[], failed-
1764        // attempt tried/because), confidence values, tags, and a
1765        // representative of every other conclusion class so no class
1766        // drops fields silently.
1767        use crate::schema::{Author, AuthorKind, LedgerEntry, LedgerKind};
1768
1769        let (engine, _) = engine_with_decisions_in_order(&[]);
1770        let ledger = AsgLedgerStore::from_engine(&engine);
1771
1772        let mk = |kind: LedgerKind, id: &str, summary: &str| -> LedgerEntry {
1773            let mut e = LedgerEntry::new(
1774                "sym_order_test",
1775                kind,
1776                summary,
1777                Author {
1778                    kind: AuthorKind::Agent,
1779                    id: "t".into(),
1780                },
1781            );
1782            e.entry_id = id.to_string();
1783            e
1784        };
1785
1786        let mut originals: Vec<LedgerEntry> = Vec::new();
1787
1788        // Thinking class: all four kinds.
1789        let mut hyp = mk(
1790            LedgerKind::Hypothesis,
1791            "led_rt_hyp",
1792            "suspect the cache is stale",
1793        );
1794        hyp.confidence = Some(0.85);
1795        hyp.body = Some("saw stale reads under load".into());
1796        hyp.tags = vec!["source:asd-think".into()];
1797        originals.push(hyp);
1798
1799        let mut mm = mk(
1800            LedgerKind::MentalModel,
1801            "led_rt_mm",
1802            "pipeline flows input -> mix -> out",
1803        );
1804        mm.body = Some(r#"{"symbols":["pkg.target","pkg.other"],"name":"audio-pipeline"}"#.into());
1805        originals.push(mm);
1806
1807        let mut fa = mk(
1808            LedgerKind::FailedAttempt,
1809            "led_rt_fa",
1810            "tried batching, failed on ordering",
1811        );
1812        fa.body = Some(r#"{"tried":"batch writes","because":"reorders events"}"#.into());
1813        fa.confidence = Some(0.4);
1814        originals.push(fa);
1815
1816        let mut oq = mk(
1817            LedgerKind::OpenQuestion,
1818            "led_rt_oq",
1819            "what does constant 4096 mean?",
1820        );
1821        oq.body = Some("partial finding: it's a page size somewhere".into());
1822        originals.push(oq);
1823
1824        // One representative per non-thinking class, with confidence
1825        // set where it previously vanished on import.
1826        let mut dec = mk(LedgerKind::Decision, "led_rt_dec", "chose sqlite");
1827        dec.confidence = Some(0.9);
1828        originals.push(dec);
1829        let mut own = mk(LedgerKind::Ownership, "led_rt_own", "owned by core team");
1830        own.role = Some("fixture-path".into());
1831        originals.push(own);
1832        let mut map = mk(LedgerKind::Mapping, "led_rt_map", "coverage moved");
1833        map.body = Some(r#"{"from_qname":"a","to_qname":"b"}"#.into());
1834        originals.push(map);
1835        originals.push(mk(LedgerKind::Hazard, "led_rt_haz", "races under load"));
1836        let mut val = mk(
1837            LedgerKind::ValidationScenario,
1838            "led_rt_val",
1839            "replay the file",
1840        );
1841        val.command = Some("cargo test -p core".into());
1842        originals.push(val);
1843        let mut fu = mk(LedgerKind::FollowUp, "led_rt_fu", "migrate diagnostics");
1844        fu.supersedes = vec!["led_rt_old".into()];
1845        originals.push(fu);
1846
1847        for e in &originals {
1848            ledger.append_entry(&engine.ref_name, e, "t").unwrap();
1849        }
1850
1851        let tmp = tempfile::tempdir().unwrap();
1852        export_all(&engine, tmp.path()).unwrap();
1853
1854        // "Wipe the ledger": fresh engine, same symbol, empty ledger.
1855        let engine2 = Engine::open_in_memory().unwrap();
1856        let sym = crate::schema::Symbol {
1857            symbol_id: "sym_order_test".into(),
1858            symbol_fp: "fp".into(),
1859            qname: "pkg.target".into(),
1860            language: "python".into(),
1861            kind: SymbolKind::Function,
1862            file: "src/target.py".into(),
1863            start: crate::schema::Position { line: 1, col: 0 },
1864            end: crate::schema::Position { line: 5, col: 0 },
1865            signature: None,
1866            doc: None,
1867        };
1868        AsgIndexStore::from_engine(&engine2)
1869            .put_symbol(&engine2.ref_name, &sym, "t")
1870            .unwrap();
1871
1872        let results = import_all(&engine2, tmp.path(), "t").unwrap();
1873
1874        // Counted, not silent: every class reports read == imported,
1875        // zero skips — including thinking.
1876        for r in &results {
1877            assert_eq!(
1878                r.read, r.imported,
1879                "class `{}` must import everything it reads; got {r:?}",
1880                r.class
1881            );
1882            assert_eq!(r.skipped_parse_error, 0, "class `{}`: {r:?}", r.class);
1883            assert_eq!(r.skipped_unknown_qname, 0, "class `{}`: {r:?}", r.class);
1884        }
1885        let thinking = results
1886            .iter()
1887            .find(|r| r.class == "thinking")
1888            .expect("thinking class must be reported");
1889        assert_eq!(
1890            thinking.imported, 4,
1891            "all four thinking kinds must import; got {thinking:?}"
1892        );
1893        let total: usize = results.iter().map(|r| r.imported).sum();
1894        assert_eq!(total, originals.len());
1895
1896        // Field-level diff: every original entry must come back
1897        // byte-equal at the JSON level (entry_id, kind, summary, body,
1898        // confidence, tags, role, command, supersedes, evidence,
1899        // author, created_at).
1900        let imported = AsgLedgerStore::from_engine(&engine2)
1901            .list_entries(&engine2.ref_name, "sym_order_test")
1902            .unwrap();
1903        assert_eq!(imported.len(), originals.len());
1904        let by_id: std::collections::BTreeMap<String, &LedgerEntry> =
1905            imported.iter().map(|e| (e.entry_id.clone(), e)).collect();
1906        for orig in &originals {
1907            let got = by_id
1908                .get(&orig.entry_id)
1909                .unwrap_or_else(|| panic!("entry {} missing after import", orig.entry_id));
1910            assert_eq!(
1911                serde_json::to_value(orig).unwrap(),
1912                serde_json::to_value(got).unwrap(),
1913                "entry {} must round-trip losslessly",
1914                orig.entry_id
1915            );
1916        }
1917    }
1918
1919    #[test]
1920    fn import_counts_thinking_and_unknown_kinds_as_reads() {
1921        // A future/unknown kind string must not import, but must show
1922        // up in `read` and `skipped_parse_error` so the drop is
1923        // visible. Hand-write a thinking.jsonl with one good and one
1924        // unknown-kind row.
1925        let (engine, _) = engine_with_decisions_in_order(&[]);
1926        let tmp = tempfile::tempdir().unwrap();
1927        std::fs::write(
1928            tmp.path().join("thinking.jsonl"),
1929            concat!(
1930                r#"{"id":"led_ok","kind":"open_question","qname":"pkg.target","file":"src/target.py","summary":"q?","author":"agent:t","created_at":"2026-05-19T20:00:00+00:00"}"#,
1931                "\n",
1932                r#"{"id":"led_bad","kind":"from_the_future","qname":"pkg.target","file":"src/target.py","summary":"?","author":"agent:t","created_at":"2026-05-19T20:00:00+00:00"}"#,
1933                "\n",
1934            ),
1935        )
1936        .unwrap();
1937
1938        let results = import_all(&engine, tmp.path(), "t").unwrap();
1939        let thinking = results
1940            .iter()
1941            .find(|r| r.class == "thinking")
1942            .expect("thinking reported");
1943        assert_eq!(thinking.read, 2);
1944        assert_eq!(thinking.imported, 1);
1945        assert_eq!(thinking.skipped_parse_error, 1);
1946        assert_eq!(
1947            thinking.read,
1948            thinking.imported + thinking.skipped_unknown_qname + thinking.skipped_parse_error,
1949            "read must equal imported + skips"
1950        );
1951    }
1952
1953    #[test]
1954    fn confidence_survives_export_import_for_thinking_and_decisions() {
1955        // Focused regression for the confidence drop: export writes
1956        // `confidence`, import restores it (previously hardcoded None).
1957        let (engine, _) = engine_with_decisions_in_order(&[]);
1958        append_thinking_entry(
1959            &engine,
1960            LedgerKind::Hypothesis,
1961            "led_conf_hyp",
1962            "confident hypothesis",
1963            Some(0.75),
1964        );
1965        append_thinking_entry(
1966            &engine,
1967            LedgerKind::Decision,
1968            "led_conf_dec",
1969            "confident decision",
1970            Some(0.6),
1971        );
1972
1973        let tmp = tempfile::tempdir().unwrap();
1974        export_all(&engine, tmp.path()).unwrap();
1975
1976        // The serialized lines must carry the confidence field.
1977        let thinking_body = std::fs::read_to_string(tmp.path().join("thinking.jsonl")).unwrap();
1978        assert!(
1979            thinking_body.contains("\"confidence\":0.75"),
1980            "export must serialize confidence; got:\n{thinking_body}"
1981        );
1982
1983        let engine2 = Engine::open_in_memory().unwrap();
1984        let sym = crate::schema::Symbol {
1985            symbol_id: "sym_order_test".into(),
1986            symbol_fp: "fp".into(),
1987            qname: "pkg.target".into(),
1988            language: "python".into(),
1989            kind: SymbolKind::Function,
1990            file: "src/target.py".into(),
1991            start: crate::schema::Position { line: 1, col: 0 },
1992            end: crate::schema::Position { line: 5, col: 0 },
1993            signature: None,
1994            doc: None,
1995        };
1996        AsgIndexStore::from_engine(&engine2)
1997            .put_symbol(&engine2.ref_name, &sym, "t")
1998            .unwrap();
1999        import_all(&engine2, tmp.path(), "t").unwrap();
2000
2001        let entries = AsgLedgerStore::from_engine(&engine2)
2002            .list_entries(&engine2.ref_name, "sym_order_test")
2003            .unwrap();
2004        let conf_of = |id: &str| {
2005            entries
2006                .iter()
2007                .find(|e| e.entry_id == id)
2008                .unwrap_or_else(|| panic!("{id} must import"))
2009                .confidence
2010        };
2011        assert_eq!(conf_of("led_conf_hyp"), Some(0.75));
2012        assert_eq!(conf_of("led_conf_dec"), Some(0.6));
2013    }
2014
2015    fn rec(id: &str, summary: &str) -> ExportRecord {
2016        ExportRecord {
2017            id: id.into(),
2018            kind: "decision".into(),
2019            qname: "App.A".into(),
2020            file: "a.rs".into(),
2021            summary: summary.into(),
2022            body: None,
2023            confidence: None,
2024            role: None,
2025            command: None,
2026            tags: vec![],
2027            evidence: vec![],
2028            supersedes: vec![],
2029            author: "agent:c".into(),
2030            created_at: "2026-05-19T20:00:00+00:00".into(),
2031        }
2032    }
2033
2034    #[test]
2035    fn merge_unions_dedups_and_sorts_by_id() {
2036        let tmp = tempdir().unwrap();
2037        let ours = tmp.path().join("ours.jsonl");
2038        let theirs = tmp.path().join("theirs.jsonl");
2039        // Out-of-order on purpose; `led_a` appears on both sides.
2040        write_jsonl(&ours, &[rec("led_b", "b"), rec("led_a", "a")]).unwrap();
2041        write_jsonl(&theirs, &[rec("led_a", "a"), rec("led_c", "c")]).unwrap();
2042
2043        let n = merge_jsonl(&ours, &theirs).unwrap();
2044        assert_eq!(n, 3, "union of {{a,b}} and {{a,c}} is {{a,b,c}}");
2045
2046        // Byte-identical to a fresh canonical export of the unioned set.
2047        let expected_path = tmp.path().join("expected.jsonl");
2048        write_jsonl(
2049            &expected_path,
2050            &[rec("led_a", "a"), rec("led_b", "b"), rec("led_c", "c")],
2051        )
2052        .unwrap();
2053        assert_eq!(
2054            std::fs::read(&ours).unwrap(),
2055            std::fs::read(&expected_path).unwrap(),
2056            "merge output must match canonical id-sorted export bytes"
2057        );
2058    }
2059
2060    #[test]
2061    fn merge_is_idempotent() {
2062        let tmp = tempdir().unwrap();
2063        let ours = tmp.path().join("ours.jsonl");
2064        let theirs = tmp.path().join("theirs.jsonl");
2065        write_jsonl(&ours, &[rec("led_a", "a")]).unwrap();
2066        write_jsonl(&theirs, &[rec("led_b", "b")]).unwrap();
2067        merge_jsonl(&ours, &theirs).unwrap();
2068        let once = std::fs::read(&ours).unwrap();
2069        merge_jsonl(&ours, &theirs).unwrap();
2070        assert_eq!(once, std::fs::read(&ours).unwrap());
2071    }
2072
2073    #[test]
2074    fn merge_treats_missing_side_as_empty() {
2075        let tmp = tempdir().unwrap();
2076        let ours = tmp.path().join("ours.jsonl");
2077        let missing = tmp.path().join("does_not_exist.jsonl");
2078        write_jsonl(&ours, &[rec("led_b", "b"), rec("led_a", "a")]).unwrap();
2079        let n = merge_jsonl(&ours, &missing).unwrap();
2080        assert_eq!(n, 2);
2081        // Still canonicalized (sorted) even with an absent counterpart.
2082        let expected = tmp.path().join("expected.jsonl");
2083        write_jsonl(&expected, &[rec("led_a", "a"), rec("led_b", "b")]).unwrap();
2084        assert_eq!(
2085            std::fs::read(&ours).unwrap(),
2086            std::fs::read(&expected).unwrap()
2087        );
2088    }
2089}
2090
2091#[cfg(test)]
2092mod additive_export_tests {
2093    use super::*;
2094    use std::collections::HashSet;
2095
2096    fn rec(id: &str, summary: &str) -> ExportRecord {
2097        ExportRecord {
2098            id: id.to_string(),
2099            kind: "decision".to_string(),
2100            qname: "pkg.thing".to_string(),
2101            file: "src/lib.rs".to_string(),
2102            summary: summary.to_string(),
2103            body: None,
2104            confidence: None,
2105            role: None,
2106            command: None,
2107            tags: Vec::new(),
2108            evidence: Vec::new(),
2109            supersedes: Vec::new(),
2110            author: "test".to_string(),
2111            created_at: "2026-01-01T00:00:00Z".to_string(),
2112        }
2113    }
2114
2115    fn committed(dir: &std::path::Path, recs: &[ExportRecord]) -> std::path::PathBuf {
2116        let p = dir.join("decisions.jsonl");
2117        write_jsonl(&p, recs).expect("seed committed file");
2118        p
2119    }
2120
2121    #[test]
2122    fn preserves_a_record_this_clone_cannot_produce() {
2123        // The orphaned-symbol case: `led_…ApiError:237` existed in the ledger
2124        // but its `:line` qname stopped resolving, so `gather` skipped it and
2125        // the bare export deleted it from main. This is that regression.
2126        let d = tempfile::tempdir().unwrap();
2127        let path = committed(
2128            d.path(),
2129            &[rec("led_orphan", "from another era"), rec("led_a", "old")],
2130        );
2131        let fresh = vec![rec("led_a", "current")];
2132
2133        let out = merge_preserving(&fresh, &path, &HashSet::new()).unwrap();
2134        let ids: Vec<&str> = out.iter().map(|r| r.id.as_str()).collect();
2135        assert_eq!(ids, vec!["led_a", "led_orphan"], "orphan was dropped");
2136        // Fresh wins for a record this clone CAN see.
2137        assert_eq!(out[0].summary, "current");
2138    }
2139
2140    #[test]
2141    fn does_not_resurrect_a_superseded_record() {
2142        // The trap an unconditional union would fall into.
2143        let d = tempfile::tempdir().unwrap();
2144        let path = committed(
2145            d.path(),
2146            &[rec("led_old", "superseded"), rec("led_new", "current")],
2147        );
2148        let fresh = vec![rec("led_new", "current")];
2149        let retired: HashSet<String> = ["led_old".to_string()].into_iter().collect();
2150
2151        let out = merge_preserving(&fresh, &path, &retired).unwrap();
2152        let ids: Vec<&str> = out.iter().map(|r| r.id.as_str()).collect();
2153        assert_eq!(
2154            ids,
2155            vec!["led_new"],
2156            "a deliberately retired record came back"
2157        );
2158    }
2159
2160    #[test]
2161    fn does_not_resurrect_a_below_floor_hypothesis() {
2162        // Same rule, other deliberate exclusion: a hypothesis whose confidence
2163        // dropped under DEFAULT_CONFIDENCE_FLOOR must stop shipping.
2164        let d = tempfile::tempdir().unwrap();
2165        let path = committed(d.path(), &[rec("led_weak", "speculative")]);
2166        let retired: HashSet<String> = ["led_weak".to_string()].into_iter().collect();
2167
2168        let out = merge_preserving(&[], &path, &retired).unwrap();
2169        assert!(
2170            out.is_empty(),
2171            "below-floor hypothesis resurrected: {out:?}"
2172        );
2173    }
2174
2175    #[test]
2176    fn output_stays_id_sorted_and_deduped() {
2177        // gather_buckets and merge_jsonl both maintain an id-sort; a merge that
2178        // broke it would make every subsequent export a huge spurious diff.
2179        let d = tempfile::tempdir().unwrap();
2180        let path = committed(d.path(), &[rec("led_c", "c"), rec("led_a", "a")]);
2181        let fresh = vec![rec("led_b", "b"), rec("led_a", "a2")];
2182
2183        let out = merge_preserving(&fresh, &path, &HashSet::new()).unwrap();
2184        let ids: Vec<&str> = out.iter().map(|r| r.id.as_str()).collect();
2185        assert_eq!(ids, vec!["led_a", "led_b", "led_c"]);
2186        assert_eq!(out.len(), 3, "duplicate ids survived");
2187        assert_eq!(out[0].summary, "a2", "fresh should win on conflict");
2188    }
2189
2190    #[test]
2191    fn a_missing_committed_file_is_not_an_error() {
2192        // First export in a fresh clone: nothing to preserve, and that is fine.
2193        let d = tempfile::tempdir().unwrap();
2194        let path = d.path().join("decisions.jsonl");
2195        let out = merge_preserving(&[rec("led_a", "a")], &path, &HashSet::new()).unwrap();
2196        assert_eq!(out.len(), 1);
2197    }
2198}