Skip to main content

lean_ctx/core/
handoff_ledger_authority.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The sidecar of a handoff ledger: its binding and the record of the
3//! elements that were bound when the ledger was written, with the check that
4//! the record and the stored ledger agree.
5
6use serde::{Deserialize, Serialize};
7
8use super::{HandoffLedgerV1, ORIGIN_ALL_BOUND, ORIGIN_ORIGINLESS_PART};
9
10/// The kind of a ledger element that is a copy of a stored item.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub(super) enum PartKind {
14    Finding,
15    Fact,
16}
17
18/// One element of a stored ledger that was bound when the ledger was
19/// written. Its identity is the tuple (kind, index, digest):
20///
21/// - `kind`: finding or fact;
22/// - `index`: its position in the ledger's own list of that kind AS STORED —
23///   `session.findings` or `knowledge.facts` of the serialized ledger, after
24///   every omission;
25/// - `digest`: [`LedgerPart::digest`] of the element at that position — for
26///   a fact it covers category and key as well as the value.
27///
28/// Two elements with one text are told apart by their place. No content.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub(super) struct BoundEntry {
31    pub(super) kind: PartKind,
32    pub(super) index: usize,
33    pub(super) digest: String,
34}
35
36/// Which elements of a ledger were bound at its creation, and how many: a
37/// missing entry is detectable. Written for EVERY ledger created under
38/// protection, with `count` 0 and no entries when none was bound; its
39/// absence there is an inconsistency, never "no bound parts".
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub(super) struct BoundRecord {
42    pub(super) count: usize,
43    pub(super) entries: Vec<BoundEntry>,
44}
45
46pub(super) const INCONSISTENT_CODE: &str = "store.ledger_authority_inconsistent";
47/// The tool identity the curated excerpts of a ledger are bound under.
48pub(super) const CURATED_TOOL: &str = "ctx_handoff";
49
50/// One finding or fact as it stands in a ledger.
51#[derive(Debug, Clone, Copy)]
52pub enum LedgerPart<'a> {
53    /// A finding: its summary.
54    Finding(&'a str),
55    /// A knowledge fact.
56    Fact {
57        category: &'a str,
58        key: &'a str,
59        value: &'a str,
60    },
61}
62
63impl LedgerPart<'_> {
64    /// The text a store keeps for this part, and a binding binds.
65    pub(super) fn text(&self) -> &str {
66        match *self {
67            Self::Finding(summary) => summary,
68            Self::Fact { value, .. } => value,
69        }
70    }
71
72    pub(super) fn kind(&self) -> PartKind {
73        match self {
74            Self::Finding(_) => PartKind::Finding,
75            Self::Fact { .. } => PartKind::Fact,
76        }
77    }
78
79    /// The digest of this part in the bound record of its ledger: BLAKE3
80    /// (hex) over a domain tag and, each length-prefixed, `"finding"` and the
81    /// summary — or `"fact"`, category, key and value, so that two facts with
82    /// one value do not collide and a finding never equals a fact. It is of
83    /// the exact text in the ledger and reveals none of it.
84    pub(super) fn digest(&self) -> String {
85        match *self {
86            Self::Finding(summary) => part_digest(&["finding", summary]),
87            Self::Fact {
88                category,
89                key,
90                value,
91            } => part_digest(&["fact", category, key, value]),
92        }
93    }
94}
95
96fn part_digest(fields: &[&str]) -> String {
97    let mut hash = blake3::Hasher::new();
98    hash.update(b"leanctx-ledger-bound-part-v1");
99    for field in fields {
100        hash.update(&(field.len() as u64).to_le_bytes());
101        hash.update(field.as_bytes());
102    }
103    hash.finalize().to_hex().to_string()
104}
105
106/// The sidecar of a ledger: its binding and, beside it, the record of the
107/// elements that were bound at creation. The binding's fields stay at the
108/// top level (`flatten`), so a sidecar that is one serialized `StoredBinding`
109/// — every sidecar written before the record existed — still parses, with
110/// `bound_parts: None`, and a reader of the binding alone reads a new one.
111///
112/// The record says WHICH findings and facts entered the ledger through a
113/// holding binding; the joined sources of all of them are the binding's
114/// lineage. It is exactly as trusted as the binding next to it: both lie in
115/// the one sidecar this store writes.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub(super) struct LedgerAuthority {
118    #[serde(flatten)]
119    pub(super) binding: crate::core::context_admission::stored::StoredBinding,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub(super) bound_parts: Option<BoundRecord>,
122}
123
124impl HandoffLedgerV1 {
125    /// The copies of stored items in this ledger, in the order and at the
126    /// places they are stored: (kind, index in its own list, part).
127    fn parts(&self) -> impl Iterator<Item = (PartKind, usize, LedgerPart<'_>)> {
128        let findings = self
129            .session
130            .findings
131            .iter()
132            .enumerate()
133            .map(|(index, summary)| (PartKind::Finding, index, LedgerPart::Finding(summary)));
134        let facts = self
135            .knowledge
136            .facts
137            .iter()
138            .enumerate()
139            .map(|(index, fact)| {
140                let part = LedgerPart::Fact {
141                    category: &fact.category,
142                    key: &fact.key,
143                    value: &fact.value,
144                };
145                (PartKind::Fact, index, part)
146            });
147        findings.chain(facts)
148    }
149
150    /// The bound record of this ledger as it is about to be stored:
151    /// `bound(kind, index)` says which of its elements were bound. Derived
152    /// from the final lists, so creation and load see the same places.
153    pub(super) fn bound_record(&self, bound: impl Fn(PartKind, usize) -> bool) -> BoundRecord {
154        let entries: Vec<BoundEntry> = self
155            .parts()
156            .filter(|(kind, index, _)| bound(*kind, *index))
157            .map(|(kind, index, part)| BoundEntry {
158                kind,
159                index,
160                digest: part.digest(),
161            })
162            .collect();
163        BoundRecord {
164            count: entries.len(),
165            entries,
166        }
167    }
168
169    /// Whether `authority`, the sidecar of this ledger written under
170    /// protection, agrees with the ledger as it is stored. All of:
171    ///
172    /// - the bound record is present, its count is its number of entries,
173    ///   and the entries are strictly ascending by (kind, index);
174    /// - every entry names an element that exists at that place and has the
175    ///   recorded digest (text edited, entries reordered or removed: no);
176    /// - the marker is the one creation decides for this stored ledger and
177    ///   this record ([`Self::checked_origin`] — the same function);
178    /// - the lineage agrees: it names sources other than those of curated
179    ///   excerpts only when at least one element is recorded (a record that
180    ///   lost its entries does not pass as "nothing was bound").
181    ///
182    /// Anything else withholds the whole ledger. A known-bound element is
183    /// never turned into unbound text by a damaged or missing record.
184    pub(super) fn agrees_with(&self, authority: &LedgerAuthority) -> bool {
185        let Some(record) = authority.bound_parts.as_ref() else {
186            return false;
187        };
188        let parts: Vec<(PartKind, usize, LedgerPart<'_>)> = self.parts().collect();
189        let ascending = record
190            .entries
191            .windows(2)
192            .all(|pair| (pair[0].kind, pair[0].index) < (pair[1].kind, pair[1].index));
193        let found = record.entries.iter().all(|entry| {
194            parts.iter().any(|(kind, index, part)| {
195                *kind == entry.kind && *index == entry.index && part.digest() == entry.digest
196            })
197        });
198        let marker = authority.binding.origin() == self.checked_origin(record);
199        let part_sources = authority
200            .binding
201            .file_sources()
202            .is_some_and(|(_, files)| files.iter().any(|(_, tool, _)| *tool != CURATED_TOOL));
203        record.count == record.entries.len()
204            && ascending
205            && found
206            && marker
207            && (record.count > 0 || !part_sources)
208    }
209
210    /// The marker of this ledger, created under protection, from the ledger
211    /// as it is STORED and its bound record — the one predicate of creation
212    /// and of [`Self::agrees_with`]: all-bound exactly when every finding and
213    /// fact in the stored lists is recorded as bound and the ledger carries
214    /// no other text without source authority (`has_unbound_text`: session
215    /// snapshot, task, decisions, next steps, evidence keys, workflow,
216    /// overlays); otherwise it has an originless part.
217    pub(super) fn checked_origin(&self, record: &BoundRecord) -> &'static str {
218        if record.count == self.parts().count() && !self.has_unbound_text() {
219            ORIGIN_ALL_BOUND
220        } else {
221            ORIGIN_ORIGINLESS_PART
222        }
223    }
224}