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}