Skip to main content

lean_ctx/core/
handoff_ledger.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::core::knowledge::ProjectKnowledge;
8use crate::core::protocol::ToolCallRecord;
9use crate::core::session::SessionState;
10use crate::core::workflow::WorkflowRun;
11
12#[path = "handoff_ledger_authority.rs"]
13mod authority;
14#[path = "handoff_ledger_merge.rs"]
15mod merge;
16
17pub use authority::LedgerPart;
18use authority::{BoundEntry, CURATED_TOOL, INCONSISTENT_CODE, LedgerAuthority, PartKind};
19pub use merge::{MAX_MERGE_INPUTS, MergeOutcome, merge_ledgers};
20
21pub(crate) const SCHEMA_VERSION: u32 = crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION;
22const MAX_KNOWLEDGE_FACTS: usize = 50;
23const MAX_CURATED_REFS: usize = 20;
24const MAX_LEDGER_FINDINGS: usize = 20;
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct HandoffLedgerV1 {
28    pub schema_version: u32,
29    pub created_at: String,
30    pub content_md5: String,
31    pub manifest_md5: String,
32    pub project_root: Option<String>,
33    pub agent_id: Option<String>,
34    pub client_name: Option<String>,
35    pub workflow: Option<WorkflowRun>,
36    pub session_snapshot: String,
37    pub session: SessionExcerpt,
38    pub tool_calls: ToolCallsSummary,
39    pub evidence_keys: Vec<String>,
40    pub knowledge: KnowledgeExcerpt,
41    pub curated_refs: Vec<CuratedRef>,
42    #[serde(default)]
43    pub active_overlays: Vec<serde_json::Value>,
44    /// Content digests of the ledgers this one was merged from
45    /// (`ctx_handoff merge`), in canonical order; empty for a created ledger.
46    /// Covered by `content_md5`, so the receipt of the merge is the ledger.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub merged_from: Vec<String>,
49    /// Idempotency key the ledger was created with (`ctx_handoff create
50    /// request_id`) and the digest of that request (AP07-G9); absent on a
51    /// ledger created without one. Covered by `content_md5`.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub request_id: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub request_digest: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct SessionExcerpt {
60    pub id: String,
61    pub task: Option<String>,
62    pub decisions: Vec<String>,
63    pub findings: Vec<String>,
64    pub next_steps: Vec<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct ToolCallsSummary {
69    pub total: usize,
70    pub by_tool: BTreeMap<String, u64>,
71    pub by_ctx_read_mode: BTreeMap<String, u64>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct KnowledgeExcerpt {
76    pub project_hash: Option<String>,
77    pub facts: Vec<KnowledgeFactMini>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct KnowledgeFactMini {
82    pub category: String,
83    pub key: String,
84    pub value: String,
85    pub confidence: f32,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CuratedRef {
90    pub path: String,
91    pub mode: String,
92    pub content_md5: String,
93    pub content: String,
94}
95
96#[derive(Debug, Clone)]
97pub struct CreateLedgerInput {
98    pub agent_id: Option<String>,
99    pub client_name: Option<String>,
100    pub project_root: Option<String>,
101    pub session: SessionState,
102    pub tool_calls: Vec<ToolCallRecord>,
103    pub workflow: Option<WorkflowRun>,
104    pub curated_refs: Vec<(String, String)>, // (abs_path, signatures_text)
105    /// Optional idempotency key: see [`create_ledger`].
106    pub request_id: Option<String>,
107}
108
109/// Origin markers of a ledger's binding (`StoredBinding::origin`). They say
110/// what was established about the ledger's findings and facts when it was
111/// written — a ledger is one stored unit and keeps no binding per item:
112///
113/// - [`ORIGIN_UNCHECKED`]: nothing. Every ledger written before findings and
114///   facts were checked at creation carries it, and so does a ledger written
115///   without protection, where no part is checked either;
116///
117/// The two markers of a ledger created under protection are decided from
118/// the ledger as it is STORED, by one predicate
119/// (`HandoffLedgerV1::checked_origin`): all-bound exactly when every finding
120/// and fact in the stored lists is recorded as bound AND the ledger carries
121/// no other text without source authority (`has_unbound_text`).
122///
123/// - [`ORIGIN_ALL_BOUND`]: every finding and fact in the ledger was bound to
124///   sources that held at creation, and those sources are in the ledger's
125///   lineage (also a ledger without any finding or fact);
126/// - [`ORIGIN_ORIGINLESS_PART`]: the bound parts are in the lineage as above,
127///   and at least one finding or fact had no binding.
128const ORIGIN_UNCHECKED: &str = "handoff";
129const ORIGIN_ALL_BOUND: &str = "handoff:all-bound";
130const ORIGIN_ORIGINLESS_PART: &str = "handoff:originless-part";
131
132/// What the findings and facts copied into a new ledger add to its lineage.
133struct CopiedParts {
134    /// Protection is active: every part is asked about. Otherwise none is.
135    enforced: bool,
136    /// The ledger's project as a binding names it (canonical).
137    project: Option<String>,
138    /// The sources of every bound part that is copied.
139    sources: Vec<crate::core::context_admission::stored::SourceRef>,
140}
141
142impl CopiedParts {
143    fn new(project_root: Option<&str>, enforced: bool) -> Self {
144        let project = project_root.filter(|root| !root.is_empty()).map(|root| {
145            crate::core::pathutil::canonicalize_secure(Path::new(root))
146                .ok()
147                .and_then(|path| path.to_str().map(str::to_owned))
148                .unwrap_or_else(|| root.to_owned())
149        });
150        Self {
151            enforced,
152            project,
153            sources: Vec::new(),
154        }
155    }
156
157    /// Whether `part`, a stored finding or fact, is copied into the ledger,
158    /// and if so whether it was bound: `None` — NOT copied; `Some(true)` —
159    /// copied as a bound element; `Some(false)` — copied, originless.
160    /// `names` is that one item under every identity its store may have
161    /// bound it by (`archive::bindings::held` for each):
162    ///
163    /// - bound, holding now, in the ledger's project: copied, its sources
164    ///   joined into the ledger's lineage;
165    /// - no binding under any name: copied, originless;
166    /// - bound and not holding, or bound in another project: not copied.
167    ///
168    /// It is called only for a part that is then STORED when it is copied
169    /// (`create_ledger` cuts its lists first), so no source of a part that
170    /// is not in the ledger is ever joined.
171    ///
172    /// Without protection every part is copied (`Some(false)`) and nothing
173    /// is asked or joined: such a ledger is marked [`ORIGIN_UNCHECKED`].
174    fn admit(
175        &mut self,
176        names: &[crate::core::archive::bindings::Item<'_>],
177        part: &LedgerPart<'_>,
178        reader: Option<&str>,
179    ) -> Option<bool> {
180        if !self.enforced {
181            return Some(false);
182        }
183        let mut held = Vec::new();
184        for item in names {
185            match crate::core::archive::bindings::held(item, part.text(), reader) {
186                Ok(Some((project, sources))) if self.in_project(&project) => held.extend(sources),
187                Ok(None) => {}
188                Ok(Some(_)) | Err(_) => return None,
189            }
190        }
191        let bound = !held.is_empty();
192        self.sources.extend(held);
193        Some(bound)
194    }
195
196    fn in_project(&self, project: &str) -> bool {
197        self.project.as_deref() == Some(project)
198    }
199}
200
201/// Create and store a ledger of the session in `input`.
202///
203/// With a `request_id` (AP07-G9) the call is idempotent for its agent in its
204/// project: a ledger stored with the same key and the same request (the
205/// curated paths, in order) is served again (`load_ledger`) instead of a
206/// second one being written; the same key with another request is refused.
207/// The key is stored in its ledger, so it lives exactly as long as that
208/// ledger (`clear` removes both). A key needs a project: a project-less
209/// ledger is served to no one and could never be replayed.
210pub fn create_ledger(input: CreateLedgerInput) -> Result<(HandoffLedgerV1, PathBuf), String> {
211    let CreateLedgerInput {
212        agent_id,
213        client_name,
214        project_root,
215        mut session,
216        tool_calls,
217        workflow,
218        curated_refs,
219        request_id,
220    } = input;
221    // A keyed create holds its (agent, project) lock from the lookup of the
222    // key to the write of its ledger: two first calls with one key cannot
223    // both miss the lookup and each store a ledger (C03).
224    let (_keyed_create, request_digest) = match request_id.as_deref() {
225        Some(key) => {
226            crate::core::agents::check_request_id(key)?;
227            let Some(root) = project_root.as_deref() else {
228                return Err("request_id requires a project".to_string());
229            };
230            let lock = keyed_create_lock(root, agent_id.as_deref())?;
231            let paths: Vec<&str> = curated_refs
232                .iter()
233                .take(MAX_CURATED_REFS)
234                .map(|(path, _)| path.as_str())
235                .collect();
236            let digest = crate::core::agents::request_digest(
237                "leanctx/handoff-create/v1",
238                &serde_json::json!(paths),
239            );
240            if let Some(path) = keyed_ledger(root, agent_id.as_deref(), key, &digest)? {
241                return load_ledger(&path).map(|ledger| (ledger, path));
242            }
243            #[cfg(test)]
244            keyed_race::pause();
245            (Some(lock), Some(digest))
246        }
247        None => (None, None),
248    };
249    let manifest_md5 = manifest_md5();
250
251    let mut evidence_keys: BTreeSet<String> = BTreeSet::new();
252    for ev in &session.evidence {
253        evidence_keys.insert(ev.key.clone());
254    }
255
256    let mut by_tool: BTreeMap<String, u64> = BTreeMap::new();
257    let mut by_mode: BTreeMap<String, u64> = BTreeMap::new();
258    for call in &tool_calls {
259        *by_tool.entry(call.tool.clone()).or_insert(0) += 1;
260        if call.tool == "ctx_read"
261            && let Some(m) = call.mode.as_deref()
262        {
263            *by_mode.entry(m.to_string()).or_insert(0) += 1;
264        }
265    }
266
267    // Findings and facts are copies of stored items. The session copy is cut
268    // to the findings a ledger keeps — the last ones — BEFORE any is asked
269    // about, and each of those is then asked about once
270    // (`CopiedParts::admit`): a bound finding that holds brings its sources
271    // into the ledger's lineage, one without a binding is copied originless,
272    // a bound one that does not hold is removed. What is left is exactly the
273    // stored list, in its order: the flags below are indexed like it, no
274    // source of a finding outside it is joined, and the snapshot below is
275    // built from this same list — it cannot carry a finding that was cut or
276    // removed.
277    let authority_required = crate::core::context_admission::stored::source_authority_required();
278    let mut parts = CopiedParts::new(project_root.as_deref(), authority_required);
279    let (session_id, session_root) = (session.id.clone(), session.project_root.clone());
280    let cut = session.findings.len().saturating_sub(MAX_LEDGER_FINDINGS);
281    session.findings.drain(..cut);
282    let mut finding_bound: Vec<bool> = Vec::new();
283    session.findings.retain(|finding| {
284        let item = crate::core::archive::bindings::Item::SessionFinding {
285            session: &session_id,
286            file: finding.file.as_deref(),
287        };
288        let part = LedgerPart::Finding(&finding.summary);
289        let copied = parts.admit(&[item], &part, session_root.as_deref());
290        finding_bound.extend(copied);
291        copied.is_some()
292    });
293
294    let session_excerpt = SessionExcerpt {
295        id: session.id.clone(),
296        task: session.task.as_ref().map(|t| t.description.clone()),
297        decisions: session
298            .decisions
299            .iter()
300            .rev()
301            .take(10)
302            .map(|d| d.summary.clone())
303            .collect::<Vec<_>>()
304            .into_iter()
305            .rev()
306            .collect(),
307        findings: session.findings.iter().map(|f| f.summary.clone()).collect(),
308        next_steps: session.next_steps.iter().take(20).cloned().collect(),
309    };
310
311    let (knowledge_excerpt, fact_bound) =
312        build_knowledge_excerpt(project_root.as_deref(), &mut parts);
313
314    // Source lineage of the ledger: every curated excerpt is bound to the file
315    // it was derived from, read now through the admitted rooted reader. While
316    // protection is active an excerpt whose source cannot be bound is left
317    // out; Community developer mode keeps it as caller-supplied text. The
318    // sources of the bound findings and facts are joined to these; more than
319    // the lineage bound refuses the ledger (`Lineage::of`), never truncates.
320    let mut sources = Vec::new();
321    let mut curated = Vec::new();
322    for (p, text) in curated_refs.into_iter().take(MAX_CURATED_REFS) {
323        let bound = project_root.as_deref().and_then(|root| {
324            crate::core::context_admission::stored::SourceRef::read(&p, root, CURATED_TOOL).ok()
325        });
326        match bound {
327            Some((_, source)) => sources.push(source),
328            None if authority_required => continue,
329            None => {}
330        }
331        let md5 = crate::core::hasher::hash_hex(text.as_bytes());
332        curated.push(CuratedRef {
333            path: p,
334            mode: "signatures".to_string(),
335            content_md5: md5,
336            content: text,
337        });
338    }
339    sources.extend(parts.sources);
340
341    let mut ledger = HandoffLedgerV1 {
342        schema_version: SCHEMA_VERSION,
343        created_at: chrono::Local::now().to_rfc3339(),
344        content_md5: String::new(),
345        manifest_md5,
346        project_root,
347        agent_id,
348        client_name,
349        workflow,
350        session_snapshot: session.build_compaction_snapshot(),
351        session: session_excerpt,
352        tool_calls: ToolCallsSummary {
353            total: tool_calls.len(),
354            by_tool,
355            by_ctx_read_mode: by_mode,
356        },
357        evidence_keys: evidence_keys.into_iter().collect(),
358        knowledge: knowledge_excerpt,
359        curated_refs: curated,
360        active_overlays: {
361            let overlay_store = crate::core::context_overlay::OverlayStore::load_project(
362                &std::env::current_dir().unwrap_or_default(),
363            );
364            overlay_store
365                .all()
366                .iter()
367                .filter_map(|o| serde_json::to_value(o).ok())
368                .collect()
369        },
370        merged_from: Vec::new(),
371        request_id,
372        request_digest,
373    };
374
375    let md5 = ledger_content_md5(&ledger);
376    ledger.content_md5.clone_from(&md5);
377
378    let path = persist_ledger(&ledger, sources, |kind, index| {
379        let flags = match kind {
380            PartKind::Finding => &finding_bound,
381            PartKind::Fact => &fact_bound,
382        };
383        flags.get(index).copied().unwrap_or(false)
384    })?;
385    Ok((ledger, path))
386}
387
388/// Admit and store `ledger` (its `content_md5` already set) with its
389/// lineage `sources`; `bound(kind, index)` says which of its stored findings
390/// and facts were bound. The one write path of created and merged ledgers.
391fn persist_ledger(
392    ledger: &HandoffLedgerV1,
393    sources: Vec<crate::core::context_admission::stored::SourceRef>,
394    bound: impl Fn(PartKind, usize) -> bool,
395) -> Result<PathBuf, String> {
396    let authority_required = crate::core::context_admission::stored::source_authority_required();
397    let path = ledger_path(&ledger.created_at, &ledger.content_md5)?;
398    let json = serde_json::to_string_pretty(ledger).map_err(|e| format!("serialize: {e}"))?;
399    // Admission precedes persistence. The ledger is digest-bound
400    // (`content_md5`), so it is never rewritten: content the current policy
401    // would redact or withhold refuses the ledger with the typed reason.
402    // The bound record and the marker are both taken from the ledger as it
403    // is stored — the final lists — and written for every ledger created
404    // under protection, the record also with no entry.
405    let bound_parts = authority_required.then(|| ledger.bound_record(&bound));
406    let origin = bound_parts
407        .as_ref()
408        .map_or(ORIGIN_UNCHECKED, |record| ledger.checked_origin(record));
409    let text = ledger_text(&json)?;
410    let binding = admit_ledger(&text, ledger.project_root.as_deref(), sources, origin)?;
411    // Written only if it would be served right now: the whole lineage
412    // re-read against the one budget a reader uses. A ledger that fits each
413    // source alone but not together would otherwise be stored and then
414    // withheld on every read.
415    if authority_required {
416        serve_ledger(Some(&binding), &text, request_project().as_deref())?;
417    }
418    crate::config_io::write_atomic_with_backup(&path, &(json + "\n"))
419        .map_err(|e| format!("write {}: {e}", path.display()))?;
420    let authority = LedgerAuthority {
421        binding,
422        bound_parts,
423    };
424    let authority = serde_json::to_string(&authority).map_err(|e| format!("serialize: {e}"))?;
425    if let Err(error) =
426        crate::config_io::write_atomic_with_backup(&authority_path(&path), &authority)
427    {
428        // A ledger without its binding would be served as a legacy record in
429        // Community mode and never under protection: do not leave it behind.
430        let _ = std::fs::remove_file(&path);
431        return Err(format!("write {}: {error}", path.display()));
432    }
433    Ok(path)
434}
435
436/// The binding of a ledger lives next to it, so the ledger file and
437/// `HandoffLedgerV1` keep their shape. It does not end in `.json`: listings
438/// and digests of the store see ledgers only.
439fn authority_path(ledger: &Path) -> PathBuf {
440    ledger.with_extension("authority")
441}
442
443/// The text of a ledger as admission inspects it: every string value, decoded,
444/// one per line. Detectors then see the content the reader will see — not
445/// JSON escapes, and not field names next to digests.
446fn ledger_text(json: &str) -> Result<String, String> {
447    fn collect<'a>(value: &'a Value, leaves: &mut Vec<&'a str>) {
448        match value {
449            Value::String(text) if !text.is_empty() => leaves.push(text),
450            Value::Array(items) => items.iter().for_each(|item| collect(item, leaves)),
451            Value::Object(map) => map.values().for_each(|child| collect(child, leaves)),
452            _ => {}
453        }
454    }
455    let value: Value = serde_json::from_str(json).map_err(|e| format!("parse ledger: {e}"))?;
456    let mut leaves = Vec::new();
457    collect(&value, &mut leaves);
458    Ok(leaves.join("\n"))
459}
460
461fn admit_ledger(
462    json: &str,
463    project_root: Option<&str>,
464    sources: Vec<crate::core::context_admission::stored::SourceRef>,
465    origin_marker: &str,
466) -> Result<crate::core::context_admission::stored::StoredBinding, String> {
467    use crate::core::context_admission::carrier::Withheld;
468    use crate::core::context_admission::stored::{Lineage, admit_and_bind};
469    let refused = |withheld: Withheld| withheld.message("handoff ledger");
470    let lineage = Lineage::of(sources).map_err(refused)?;
471    let (admitted, binding) =
472        admit_and_bind(json, origin_marker, project_root, lineage).map_err(refused)?;
473    if admitted != json {
474        return Err(refused(Withheld::new(&["policy.content_redacted"])));
475    }
476    Ok(binding)
477}
478
479/// The project of the running request, as the reader of a stored ledger.
480fn request_project() -> Option<String> {
481    crate::core::policy::runtime::REQUEST_PROJECT
482        .try_with(|slot| slot.borrow().clone())
483        .ok()
484        .flatten()
485        .and_then(|project| project.to_str().map(str::to_owned))
486}
487
488/// The ledger text as a new delivery under the CURRENT policy: project
489/// authority, every source of its lineage, then the content rules. A ledger
490/// written before bindings existed (`binding` is `None`) follows the
491/// originless rule.
492///
493/// The lineage is the joined set of the curated excerpts and of every bound
494/// finding and fact copied at creation. The ledger text is ONE stored unit
495/// without per-item spans, so the rule is whole-ledger withholding: when any
496/// one of those sources is gone, changed, reclassified or no longer permitted,
497/// the whole ledger is withheld — not only the item that came from it. It is
498/// served again when the source delivers the bound bytes again.
499fn serve_ledger(
500    binding: Option<&crate::core::context_admission::stored::StoredBinding>,
501    text: &str,
502    reader: Option<&str>,
503) -> Result<(), String> {
504    use crate::core::context_admission::carrier::Withheld;
505    use crate::core::context_admission::stored::serve_stored;
506    let served = serve_stored(text, binding, reader)
507        .map_err(|withheld| withheld.message("handoff ledger"))?;
508    if served == text {
509        Ok(())
510    } else {
511        Err(Withheld::new(&["policy.content_redacted"]).message("handoff ledger"))
512    }
513}
514
515/// Whether a ledger stored for `stored` belongs to the project `reader`
516/// works in. A ledger without a project belongs to none (AP05-G7).
517fn same_project(stored: Option<&str>, reader: Option<&str>) -> bool {
518    let canonical =
519        |root: &str| crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000);
520    match (stored, reader) {
521        (Some(stored), Some(reader)) => canonical(stored) == canonical(reader),
522        _ => false,
523    }
524}
525
526/// The project a stored ledger file records, without serving its content.
527fn stored_project(path: &Path) -> Option<String> {
528    let json: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
529    json.get("project_root")?.as_str().map(str::to_owned)
530}
531
532/// Test seam: holds a keyed create that missed its lookup until the armed
533/// number of creates has reached the same point, or for a bounded time. A
534/// second create that cannot get past the keyed lock never arrives, so the
535/// first one goes on after the bound; without the lock both arrive and race.
536#[cfg(test)]
537pub(crate) mod keyed_race {
538    use std::sync::{Condvar, Mutex, PoisonError};
539    use std::time::{Duration, Instant};
540
541    /// (expected, arrived); `expected == 0` is unarmed.
542    static STATE: Mutex<(usize, usize)> = Mutex::new((0, 0));
543    static ARRIVAL: Condvar = Condvar::new();
544    const BOUND: Duration = Duration::from_millis(500);
545
546    /// Arm for `expected` creates until the guard drops.
547    pub(crate) fn arm(expected: usize) -> impl Drop {
548        struct Disarm;
549        impl Drop for Disarm {
550            fn drop(&mut self) {
551                *STATE.lock().unwrap_or_else(PoisonError::into_inner) = (0, 0);
552            }
553        }
554        *STATE.lock().unwrap_or_else(PoisonError::into_inner) = (expected, 0);
555        Disarm
556    }
557
558    pub(super) fn pause() {
559        let mut state = STATE.lock().unwrap_or_else(PoisonError::into_inner);
560        if state.0 == 0 {
561            return;
562        }
563        state.1 += 1;
564        ARRIVAL.notify_all();
565        let deadline = Instant::now() + BOUND;
566        while state.1 < state.0 {
567            let left = deadline.saturating_duration_since(Instant::now());
568            if left.is_zero() {
569                break;
570            }
571            state = ARRIVAL
572                .wait_timeout(state, left)
573                .unwrap_or_else(PoisonError::into_inner)
574                .0;
575        }
576    }
577}
578
579/// The lock that serializes keyed creates of `agent_id` in `project_root`
580/// across threads and processes; released on drop. Bounded: a create that
581/// cannot take it within the wait is refused, never left blocking.
582fn keyed_create_lock(
583    project_root: &str,
584    agent_id: Option<&str>,
585) -> Result<crate::core::agents::FileLock, String> {
586    const WAIT: std::time::Duration = std::time::Duration::from_secs(10);
587    let dir = handoffs_dir()?;
588    std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
589    let scope = serde_json::json!([project_root, agent_id]).to_string();
590    let name = crate::core::hasher::hash_hex(scope.as_bytes());
591    crate::core::agents::FileLock::acquire_with_timeout(
592        &dir.join(format!("keyed-{name}.lock")),
593        WAIT,
594    )
595    .map_err(|error| format!("handoff create is busy: {error}"))
596}
597
598/// The stored ledger of `project_root` that `agent_id` created with the
599/// idempotency key `key`: `Ok(Some)` when it was created by the same request
600/// (`digest`), `Err` when the key was used with another one.
601fn keyed_ledger(
602    project_root: &str,
603    agent_id: Option<&str>,
604    key: &str,
605    digest: &str,
606) -> Result<Option<PathBuf>, String> {
607    for path in list_ledgers(project_root) {
608        let Some(stored) = std::fs::read_to_string(&path)
609            .ok()
610            .and_then(|text| serde_json::from_str::<Value>(&text).ok())
611        else {
612            continue;
613        };
614        if stored["request_id"].as_str() != Some(key) || stored["agent_id"].as_str() != agent_id {
615            continue;
616        }
617        return if stored["request_digest"].as_str() == Some(digest) {
618            Ok(Some(path))
619        } else {
620            Err(crate::core::agents::request_id_conflict())
621        };
622    }
623    Ok(None)
624}
625
626/// The ledgers stored for `project_root`, newest first. Ledgers of other
627/// projects, and ledgers without a project, are not listed.
628pub fn list_ledgers(project_root: &str) -> Vec<PathBuf> {
629    let dir = handoffs_dir().ok();
630    let Some(dir) = dir else {
631        return Vec::new();
632    };
633    let Ok(rd) = std::fs::read_dir(&dir) else {
634        return Vec::new();
635    };
636    let mut items: Vec<PathBuf> = rd
637        .flatten()
638        .map(|e| e.path())
639        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
640        .filter(|p| same_project(stored_project(p).as_deref(), Some(project_root)))
641        .collect();
642    items.sort();
643    items.reverse();
644    items
645}
646
647/// The lineage a served ledger hands on to the findings and facts restored
648/// from it (`ctx_handoff pull`). It exists for every ledger that was checked
649/// at creation, has at least one bound part and was just served — also one
650/// with an originless part: the marker decides whether the ledger as a whole
651/// may register a lineage, not whether a restored part gets a binding.
652pub struct LedgerLineage {
653    binding: crate::core::context_admission::stored::StoredBinding,
654    /// The elements that were bound at creation; the record was found
655    /// consistent with the stored ledger when it was loaded.
656    bound: Vec<BoundEntry>,
657    /// The text the binding binds (`ledger_text`).
658    text: String,
659    reader: Option<String>,
660}
661
662impl LedgerLineage {
663    /// The binding of `part` — the element at `index` of its own list in the
664    /// stored ledger — restored as `derived`.
665    ///
666    /// `Ok(None)` — the item is restored originless — for an element that is
667    /// not recorded as bound at that place: it had no binding at creation. A
668    /// part is never upgraded because the ledger has bound siblings, nor
669    /// because another element has the same text.
670    ///
671    /// `Err` (`store.ledger_authority_inconsistent`) for an element that IS
672    /// recorded at that place while `part` does not have the recorded
673    /// digest: a known-bound element is refused, never restored unbound.
674    ///
675    /// A recorded element is bound to the ledger's joined source set
676    /// (`archive::bindings::carry_from`). This over-approximates per item:
677    /// the ledger keeps no per-item source lists, so the item is bound to
678    /// ALL files of the ledger, not only to those of the finding or fact it
679    /// once was. More sources have to hold, never fewer; the cost is that a
680    /// restored item is withheld when an unrelated file of the same ledger
681    /// changes.
682    pub fn carry(
683        &self,
684        part: &LedgerPart<'_>,
685        index: usize,
686        derived: &crate::core::archive::bindings::Item<'_>,
687    ) -> Result<
688        Option<crate::core::archive::bindings::Carried>,
689        crate::core::context_admission::carrier::Withheld,
690    > {
691        let recorded = self
692            .bound
693            .iter()
694            .find(|entry| entry.kind == part.kind() && entry.index == index);
695        let Some(recorded) = recorded else {
696            return Ok(None);
697        };
698        if recorded.digest != part.digest() {
699            return Err(crate::core::context_admission::carrier::Withheld::new(&[
700                INCONSISTENT_CODE,
701            ]));
702        }
703        crate::core::archive::bindings::carry_from(
704            derived,
705            part.text(),
706            &self.binding,
707            &self.text,
708            self.reader.as_deref(),
709        )
710    }
711}
712
713/// A stored ledger for a reader that only renders it (`ctx_handoff show`).
714pub fn load_ledger(path: &Path) -> Result<HandoffLedgerV1, String> {
715    load_ledger_bound(path).map(|(ledger, _)| ledger)
716}
717
718/// The `ctx_handoff show` reply for a loaded ledger.
719pub fn format_show(path: &Path, ledger: &HandoffLedgerV1) -> String {
720    let mut out = serde_json::to_string_pretty(ledger).unwrap_or_else(|_| "{}".to_string());
721    out.push('\n');
722    format!("ctx_handoff show\n path: {}\n{}", path.display(), out)
723}
724
725/// A stored ledger, and the lineage its restored items inherit
726/// ([`LedgerLineage`]; `None` for an unchecked ledger and for a ledger none
727/// of whose parts was bound — every item restored from those is originless).
728///
729/// Every reader of a stored ledger (`show`, `pull`) passes here, and the
730/// ledger is re-authorized before it is parsed, rendered or handed on
731/// (`serve_ledger`: whole-ledger withholding). An `Err` is a typed,
732/// content-free refusal, returned before anything is written anywhere.
733pub fn load_ledger_bound(path: &Path) -> Result<(HandoffLedgerV1, Option<LedgerLineage>), String> {
734    load_served(path).map(|served| (served.ledger, served.lineage))
735}
736
737/// A stored ledger as it was just served, with what a reader that derives a
738/// new ledger from it needs (`merge`): its lineage and the files its binding
739/// names, `(project, [(path, tool, admitted digest)])`.
740struct ServedLedger {
741    ledger: HandoffLedgerV1,
742    lineage: Option<LedgerLineage>,
743    files: Option<(String, Vec<(String, String, String)>)>,
744}
745
746fn load_served(path: &Path) -> Result<ServedLedger, String> {
747    use crate::core::context_admission::carrier::Withheld;
748    use crate::core::context_admission::stored;
749    // A ledger serves only the project it was stored for (AP05-G7). Missing,
750    // unreadable, another project's or project-less: one answer, decided
751    // before anything else about the file is reported.
752    let reader = request_project();
753    let not_found = || format!("handoff ledger {} not found", path.display());
754    let s = std::fs::read_to_string(path).map_err(|_| not_found())?;
755    let stored: Value = serde_json::from_str(&s).map_err(|_| not_found())?;
756    if !same_project(stored["project_root"].as_str(), reader.as_deref()) {
757        return Err(not_found());
758    }
759    let text = ledger_text(&s)?;
760    // The sidecar. One that is missing or does not parse — also a damaged
761    // bound record — is no binding at all: the originless rule below.
762    let authority: Option<LedgerAuthority> = std::fs::read_to_string(authority_path(path))
763        .ok()
764        .and_then(|authority| serde_json::from_str(&authority).ok());
765    let binding = authority.as_ref().map(|authority| &authority.binding);
766    let ledger: HandoffLedgerV1 = match serde_json::from_str(&s) {
767        Ok(ledger) => ledger,
768        Err(e) => {
769            // Not a ledger: authorized like any stored text before the
770            // parse error — which may quote a value — is reported.
771            serve_ledger(binding, &text, reader.as_deref())?;
772            return Err(format!("parse {}: {e}", path.display()));
773        }
774    };
775    // A ledger written under protection must agree with its bound record
776    // (`agrees_with`), and that is established first, on digests alone.
777    // Disagreement — or no record at all — withholds the whole ledger:
778    // nothing is shown, nothing is restored, and a part that was known as
779    // bound is never downgraded to unbound text.
780    let checked = authority
781        .as_ref()
782        .filter(|authority| checked_at_creation(authority.binding.origin()));
783    if checked.is_some_and(|authority| !ledger.agrees_with(authority)) {
784        return Err(Withheld::new(&[INCONSISTENT_CODE]).message("handoff ledger"));
785    }
786    serve_ledger(binding, &text, reader.as_deref())?;
787    // An unchecked ledger (written before findings and facts were checked at
788    // creation, or without protection): its curated sources were re-read
789    // above, but its findings and facts were never bound to anything. While
790    // protection is active they are text without source authority, and the
791    // ledger is one unit — it is withheld. A ledger checked at creation is
792    // not caught here: its originless part follows the originless rule
793    // through its own marker (served, never registered).
794    let unchecked = binding.is_some_and(|binding| !checked_at_creation(binding.origin()));
795    if unchecked && ledger.has_copied_parts() && stored::source_authority_required() {
796        return Err(Withheld::no_source_authority().message("handoff ledger"));
797    }
798    // Delivered lineage: the binding names the files of the curated excerpts
799    // and of the bound findings and facts, and nothing else. Only a ledger
800    // that consists of those has that lineage (`has_file_lineage`); with any
801    // other text it has none (unknown), whatever its sources are.
802    stored::publish_served(binding.filter(|binding| ledger.has_file_lineage(binding.origin())));
803    let files = binding
804        .and_then(|binding| binding.file_sources())
805        .map(|(project, files)| {
806            let files = files
807                .into_iter()
808                .map(|(path, tool, digest)| (path.to_owned(), tool.to_owned(), digest.to_owned()))
809                .collect();
810            (project.to_owned(), files)
811        });
812    // What restored items inherit. An unchecked ledger has none: nothing
813    // was ever known as bound there, and its record — if any — is not read.
814    let lineage = authority
815        .filter(|authority| checked_at_creation(authority.binding.origin()))
816        .and_then(|authority| {
817            let record = authority.bound_parts.filter(|record| record.count > 0)?;
818            Some(LedgerLineage {
819                binding: authority.binding,
820                bound: record.entries,
821                text,
822                reader,
823            })
824        });
825    Ok(ServedLedger {
826        ledger,
827        lineage,
828        files,
829    })
830}
831
832/// Whether a ledger's findings and facts were checked when it was written.
833/// Any other marker — the one of older ledgers, or one this version does not
834/// know — is treated as unchecked.
835fn checked_at_creation(origin: &str) -> bool {
836    origin == ORIGIN_ALL_BOUND || origin == ORIGIN_ORIGINLESS_PART
837}
838
839impl HandoffLedgerV1 {
840    /// Whether the ledger carries copies of stored findings or facts.
841    fn has_copied_parts(&self) -> bool {
842        !self.session.findings.is_empty() || !self.knowledge.facts.is_empty()
843    }
844
845    /// Whether every text of the ledger that came from a store has its
846    /// source in the ledger's binding, whose marker is `origin`: the curated
847    /// excerpts always, findings and facts only in a ledger marked
848    /// [`ORIGIN_ALL_BOUND`]. The session snapshot, task, decisions, next
849    /// steps, evidence keys, a workflow and overlays are copies from stores
850    /// that keep no source binding; a ledger with any of them has no file
851    /// lineage. (`create_ledger` always writes a snapshot, so in practice
852    /// this holds for snapshot-free ledgers only.)
853    fn has_file_lineage(&self, origin: &str) -> bool {
854        !self.has_unbound_text() && (origin == ORIGIN_ALL_BOUND || !self.has_copied_parts())
855    }
856
857    /// Whether the stored ledger carries text that no store keeps a source
858    /// binding for: a session snapshot, a task, decisions, next steps,
859    /// evidence keys, a workflow or overlays. The one list of such text, for
860    /// the marker (`checked_origin`) and for publication alike.
861    fn has_unbound_text(&self) -> bool {
862        !(self.session_snapshot.is_empty()
863            && self.session.task.is_none()
864            && self.session.decisions.is_empty()
865            && self.session.next_steps.is_empty()
866            && self.evidence_keys.is_empty()
867            && self.workflow.is_none()
868            && self.active_overlays.is_empty())
869    }
870}
871
872pub(crate) fn compute_content_md5_for_ledger(ledger: &HandoffLedgerV1) -> String {
873    ledger_content_md5(ledger)
874}
875
876/// Stored ledgers that name no project: served to no project (AP05-G7).
877/// Read for the explicit `lean-ctx migrate legacy-context` adoption only.
878pub fn projectless_ledgers() -> Vec<(PathBuf, HandoffLedgerV1)> {
879    let Ok(rd) = handoffs_dir().and_then(|dir| std::fs::read_dir(dir).map_err(|e| e.to_string()))
880    else {
881        return Vec::new();
882    };
883    let mut items: Vec<(PathBuf, HandoffLedgerV1)> = rd
884        .flatten()
885        .map(|e| e.path())
886        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
887        .filter_map(|p| {
888            let ledger: HandoffLedgerV1 =
889                serde_json::from_str(&std::fs::read_to_string(&p).ok()?).ok()?;
890            ledger.project_root.is_none().then_some((p, ledger))
891        })
892        .collect();
893    items.sort_by(|a, b| a.0.cmp(&b.0));
894    items
895}
896
897/// Store a copy of the project-less ledger at `path` as a ledger of
898/// `project_root`, through the admission of a newly created ledger. The
899/// original stays untouched.
900pub fn adopt_projectless_ledger(path: &Path, project_root: &str) -> Result<PathBuf, String> {
901    let mut ledger: HandoffLedgerV1 = serde_json::from_str(
902        &std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?,
903    )
904    .map_err(|e| format!("parse {}: {e}", path.display()))?;
905    if ledger.project_root.is_some() {
906        return Err("ledger already belongs to a project".into());
907    }
908    ledger.project_root = Some(project_root.to_string());
909    ledger.content_md5 = ledger_content_md5(&ledger);
910    persist_ledger(&ledger, Vec::new(), |_, _| false)
911}
912
913/// Remove the ledgers of `project_root`; other projects' ledgers stay.
914pub fn clear_ledgers(project_root: &str) -> Result<u32, String> {
915    let mut removed = 0u32;
916    for p in list_ledgers(project_root) {
917        if std::fs::remove_file(&p).is_ok() {
918            removed += 1;
919        }
920        let _ = std::fs::remove_file(authority_path(&p));
921    }
922    Ok(removed)
923}
924
925fn build_knowledge_excerpt(
926    project_root: Option<&str>,
927    parts: &mut CopiedParts,
928) -> (KnowledgeExcerpt, Vec<bool>) {
929    use crate::core::archive::bindings::Item;
930    let Some(knowledge) = project_root.and_then(ProjectKnowledge::load) else {
931        return (KnowledgeExcerpt::default(), Vec::new());
932    };
933    // Whether the fact at the same index of `facts` was bound.
934    let mut bound = Vec::new();
935
936    // A fact is bound under the project root its writer's request named:
937    // this store's root, or the request's spelling of it. It is asked about
938    // under both, with the running request as its reader — the facts
939    // `ProjectKnowledge::delivery_filter` delivers, here with their sources.
940    let request = crate::core::admitted_sources::request_root();
941    let reader = Some(request.as_str()).filter(|root| !root.is_empty());
942    let one_name = reader.is_none_or(|root| root == knowledge.project_root);
943    let mut facts = Vec::new();
944    for f in knowledge.facts.iter().filter(|f| f.is_current()) {
945        let names = [
946            Item::KnowledgeFact {
947                project: &knowledge.project_root,
948                category: &f.category,
949                key: &f.key,
950            },
951            Item::KnowledgeFact {
952                project: &request,
953                category: &f.category,
954                key: &f.key,
955            },
956        ];
957        let names = if one_name { &names[..1] } else { &names[..] };
958        let part = LedgerPart::Fact {
959            category: &f.category,
960            key: &f.key,
961            value: &f.value,
962        };
963        let Some(part_bound) = parts.admit(names, &part, reader) else {
964            continue;
965        };
966        bound.push(part_bound);
967        facts.push(KnowledgeFactMini {
968            category: f.category.clone(),
969            key: f.key.clone(),
970            value: f.value.clone(),
971            confidence: f.confidence,
972        });
973        if facts.len() >= MAX_KNOWLEDGE_FACTS {
974            break;
975        }
976    }
977
978    let excerpt = KnowledgeExcerpt {
979        project_hash: Some(knowledge.project_hash.clone()),
980        facts,
981    };
982    (excerpt, bound)
983}
984
985fn ledger_path(created_at: &str, md5: &str) -> Result<PathBuf, String> {
986    let dir = handoffs_dir()?;
987    std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
988    let ts = created_at
989        .chars()
990        .filter(char::is_ascii_digit)
991        .take(14)
992        .collect::<String>();
993    let name = format!("{ts}-{md5}.json");
994    Ok(dir.join(name))
995}
996
997fn handoffs_dir() -> Result<PathBuf, String> {
998    let dir = crate::core::data_dir::lean_ctx_data_dir()
999        .map_err(|e| e.clone())?
1000        .join("handoffs");
1001    Ok(dir)
1002}
1003
1004fn manifest_md5() -> String {
1005    let v = crate::core::mcp_manifest::manifest_value();
1006    let canon = crate::core::canonical::canonical_value(&v);
1007    crate::core::hasher::hash_hex(canon.to_string().as_bytes())
1008}
1009
1010fn ledger_content_md5(ledger: &HandoffLedgerV1) -> String {
1011    let mut tmp = ledger.clone();
1012    tmp.content_md5.clear();
1013    let v = serde_json::to_value(&tmp).unwrap_or(Value::Null);
1014    let canon = crate::core::canonical::canonical_value(&v);
1015    crate::core::hasher::hash_hex(canon.to_string().as_bytes())
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1019pub struct HandoffPackage {
1020    pub ledger: HandoffLedgerV1,
1021    pub intent: Option<IntentSnapshot>,
1022    pub context_snapshot: Option<ContextSnapshot>,
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1026pub struct IntentSnapshot {
1027    pub task_type: String,
1028    pub scope: String,
1029    pub targets: Vec<String>,
1030    pub keywords: Vec<String>,
1031    pub language_hint: Option<String>,
1032    pub urgency: f64,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1036pub struct ContextSnapshot {
1037    pub window_size: usize,
1038    pub tokens_used: usize,
1039    pub tokens_saved: usize,
1040    pub files_loaded: Vec<LoadedFileInfo>,
1041}
1042
1043#[derive(Debug, Clone, Serialize, Deserialize)]
1044pub struct LoadedFileInfo {
1045    pub path: String,
1046    pub mode: String,
1047    pub tokens: usize,
1048}
1049
1050impl HandoffPackage {
1051    pub fn build(
1052        ledger: HandoffLedgerV1,
1053        intent: Option<&super::intent_engine::StructuredIntent>,
1054        context: Option<&crate::core::context_ledger::ContextLedger>,
1055    ) -> Self {
1056        let intent_snap = intent.map(|i| IntentSnapshot {
1057            task_type: i.task_type.as_str().to_string(),
1058            scope: match i.scope {
1059                super::intent_engine::IntentScope::SingleFile => "single_file",
1060                super::intent_engine::IntentScope::MultiFile => "multi_file",
1061                super::intent_engine::IntentScope::CrossModule => "cross_module",
1062                super::intent_engine::IntentScope::ProjectWide => "project_wide",
1063            }
1064            .to_string(),
1065            targets: i.targets.clone(),
1066            keywords: i.keywords.clone(),
1067            language_hint: i.language_hint.clone(),
1068            urgency: i.urgency,
1069        });
1070
1071        let ctx_snap = context.map(|c| ContextSnapshot {
1072            window_size: c.window_size,
1073            tokens_used: c.total_tokens_sent,
1074            tokens_saved: c.total_tokens_saved,
1075            files_loaded: c
1076                .entries
1077                .iter()
1078                .map(|e| LoadedFileInfo {
1079                    path: e.path.clone(),
1080                    mode: e.mode.clone(),
1081                    tokens: e.sent_tokens,
1082                })
1083                .collect(),
1084        });
1085
1086        HandoffPackage {
1087            ledger,
1088            intent: intent_snap,
1089            context_snapshot: ctx_snap,
1090        }
1091    }
1092
1093    pub fn format_compact(&self) -> String {
1094        let mut out = String::new();
1095
1096        out.push_str("--- HANDOFF ---\n");
1097        if let Some(ref intent) = self.intent {
1098            out.push_str(&format!(
1099                "TASK: {} (scope: {}, conf: {})\n",
1100                intent.task_type,
1101                intent.scope,
1102                if intent.urgency > 0.5 {
1103                    "URGENT"
1104                } else {
1105                    "normal"
1106                }
1107            ));
1108            if !intent.targets.is_empty() {
1109                out.push_str(&format!("TARGETS: {}\n", intent.targets.join(", ")));
1110            }
1111            if let Some(ref lang) = intent.language_hint {
1112                out.push_str(&format!("LANG: {lang}\n"));
1113            }
1114        }
1115
1116        if let Some(ref ctx) = self.context_snapshot {
1117            out.push_str(&format!(
1118                "CTX: {}/{} tokens, {} files, saved {}\n",
1119                ctx.tokens_used,
1120                ctx.window_size,
1121                ctx.files_loaded.len(),
1122                ctx.tokens_saved,
1123            ));
1124        }
1125
1126        if !self.ledger.session.decisions.is_empty() {
1127            out.push_str("DECISIONS:\n");
1128            for d in &self.ledger.session.decisions {
1129                out.push_str(&format!("  - {d}\n"));
1130            }
1131        }
1132
1133        if !self.ledger.session.findings.is_empty() {
1134            out.push_str("FINDINGS:\n");
1135            for f in self.ledger.session.findings.iter().take(5) {
1136                out.push_str(&format!("  - {f}\n"));
1137            }
1138        }
1139
1140        if !self.ledger.session.next_steps.is_empty() {
1141            out.push_str("NEXT:\n");
1142            for s in &self.ledger.session.next_steps {
1143                out.push_str(&format!("  - {s}\n"));
1144            }
1145        }
1146
1147        out.push_str("---\n");
1148        out
1149    }
1150}
1151
1152#[cfg(test)]
1153#[path = "handoff_ledger_tests.rs"]
1154mod tests;
1155
1156#[cfg(test)]
1157#[path = "handoff_ledger_lineage_tests.rs"]
1158mod lineage_tests;