Skip to main content

lean_ctx/core/archive/
bindings.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Source bindings of stored items whose own record has no place for one:
3//! knowledge facts and session findings.
4//!
5//! The binding is kept beside the store, in a register of the archive's one
6//! persistence form (`archive::register`: owner-only shards, replaced
7//! atomically under a bounded lock, bounded in entries and age), keyed by the
8//! item's identity. The store's own format is unchanged; an item written
9//! before bindings existed, an item whose binding was evicted or has expired,
10//! and an item whose text no longer is the bound text are all the same thing:
11//! originless.
12//!
13//! A binding is written only from sources that exist AT THE WRITE: files the
14//! writer named and the admitted reader returned then ([`sources_at`]), or the
15//! complete source set of the very call that produced the item
16//! (`sources_of_call`). Nothing is inferred later. Owner decision E3: a
17//! restricted source or text binds nothing.
18
19use super::authority::ArchiveAuthority;
20use super::register::{self, Register};
21use crate::core::context_admission::carrier::Withheld;
22use crate::core::context_admission::stored::{self, Lineage, SourceRef, StoredBinding};
23
24const REGISTER: Register = Register {
25    name: "bindings",
26    shard_entries: 128,
27    max_age_seconds: 180 * 24 * 60 * 60,
28};
29
30/// The identity of a stored item.
31#[derive(Debug, Clone, Copy)]
32pub enum Item<'a> {
33    /// A knowledge fact: project root as its store names it, category, key.
34    KnowledgeFact {
35        project: &'a str,
36        category: &'a str,
37        key: &'a str,
38    },
39    /// A session finding: session id and the file it was recorded for.
40    SessionFinding {
41        session: &'a str,
42        file: Option<&'a str>,
43    },
44}
45
46impl Item<'_> {
47    fn origin(&self) -> &'static str {
48        match self {
49            Self::KnowledgeFact { .. } => "knowledge:fact",
50            Self::SessionFinding { .. } => "session:finding",
51        }
52    }
53
54    /// Register key. A finding has no key of its own, so its text is part of
55    /// its identity; a fact keeps its key when its value is replaced, and the
56    /// binding's content digest then tells the old value from the new one.
57    fn key(&self, text: &str) -> String {
58        let parts: [&str; 4] = match *self {
59            Self::KnowledgeFact {
60                project,
61                category,
62                key,
63            } => ["fact", project, category, key],
64            Self::SessionFinding { session, file } => {
65                ["finding", session, file.unwrap_or_default(), text]
66            }
67        };
68        let mut hash = blake3::Hasher::new();
69        hash.update(b"leanctx-item-binding-v1");
70        for part in parts {
71            hash.update(b"\0");
72            hash.update(&(part.len() as u64).to_le_bytes());
73            hash.update(part.as_bytes());
74        }
75        hash.finalize().to_hex().to_string()
76    }
77}
78
79/// The sources a writer names for an item, each read NOW through the admitted
80/// rooted reader for `tool`. A path relative to the project is resolved in
81/// it. `None` when no path is named or any of them cannot be bound (missing,
82/// outside the project, not permitted, restricted, only lossily readable):
83/// an item is bound to all the sources it names or to none.
84pub fn sources_at<S: AsRef<str>>(project: &str, paths: &[S], tool: &str) -> Option<Vec<SourceRef>> {
85    if paths.is_empty() {
86        return None;
87    }
88    paths
89        .iter()
90        .map(|path| {
91            let path = std::path::Path::new(project).join(path.as_ref());
92            SourceRef::read(path.to_str()?, project, tool)
93                .ok()
94                .map(|(_, source)| source)
95        })
96        .collect()
97}
98
99/// The sources of an item that was derived from the output of the current
100/// call: the call's captured source set, which the caller passes only for a
101/// tool that declares every source. Each file is read again now and must
102/// deliver what the call was given. `None` for an incomplete set.
103pub(crate) fn sources_of_call(capture: &ArchiveAuthority) -> Option<Vec<SourceRef>> {
104    let project = capture.project();
105    capture
106        .sources()?
107        .into_iter()
108        .map(|(path, tool, admitted_digest)| {
109            SourceRef::read_as_admitted(path, project, tool, admitted_digest)
110        })
111        .collect()
112}
113
114/// Bind `text`, as its store keeps it, to `sources`. Returns whether the item
115/// is bound now. `false` — the item stays originless — when the text or a
116/// source is restricted or withheld, when admission would rewrite the text
117/// (the binding would be of another text), without a project under
118/// protection, or when the register refuses the write.
119pub fn bind(item: &Item<'_>, text: &str, project: &str, sources: Vec<SourceRef>) -> bool {
120    prepare(item, text, project, sources).is_some_and(|binding| write(item, text, binding))
121}
122
123/// The binding [`bind`] would write, not yet written. `None` for every case
124/// in which [`bind`] leaves the item originless before the register is asked.
125pub(crate) fn prepare(
126    item: &Item<'_>,
127    text: &str,
128    project: &str,
129    sources: Vec<SourceRef>,
130) -> Option<StoredBinding> {
131    if sources.is_empty() {
132        return None;
133    }
134    let lineage = Lineage::of(sources).ok()?;
135    let (admitted, binding) =
136        stored::admit_and_bind(text, item.origin(), Some(project), lineage).ok()?;
137    (admitted == text).then_some(binding)
138}
139
140fn write(item: &Item<'_>, text: &str, binding: StoredBinding) -> bool {
141    REGISTER.update::<StoredBinding>(&item.key(text), register::now(), |_| Some(binding))
142}
143
144/// A binding prepared for an item that is derived from bound items, to be
145/// written once the item's store has kept the text (`carry`).
146pub struct Carried(StoredBinding);
147
148impl Carried {
149    /// Write the binding for `item`, whose store keeps `text` now. `false`
150    /// when `text` is not the text the binding was prepared for or the
151    /// register refuses the write: the item is then NOT bound, and its store
152    /// must not keep a text that was derived from bound items.
153    pub fn commit(self, item: &Item<'_>, text: &str) -> bool {
154        self.0.binds(text) && write(item, text, self.0)
155    }
156}
157
158/// The sources a binding names, each read again now. `Err` when the bound
159/// item is not served now (`stored::serve_stored`) or a source no longer
160/// delivers what the binding was made from; `Ok(None)` for a binding that
161/// names no file (caller-supplied text, no project).
162fn held_sources(
163    binding: &StoredBinding,
164    text: &str,
165    reader_project: Option<&str>,
166) -> Result<Option<(String, Vec<SourceRef>)>, Withheld> {
167    stored::serve_stored(text, Some(binding), reader_project)?;
168    let Some((project, files)) = binding.file_sources() else {
169        return Ok(None);
170    };
171    let sources = files
172        .into_iter()
173        .map(|(path, tool, admitted_digest)| {
174            SourceRef::read_as_admitted(path, project, tool, admitted_digest)
175                .ok_or_else(Withheld::source_unavailable)
176        })
177        .collect::<Result<Vec<_>, _>>()?;
178    Ok(Some((project.to_owned(), sources)))
179}
180
181/// The binding of an item whose `text` a store DERIVES from other stored
182/// items (`origins`, each with the text its store keeps): a knowledge fact
183/// built from a session finding. The derived text is not the origin's text,
184/// so the origin's binding cannot be copied; the derived item is bound to the
185/// same files, read again now through the admitted reader, exactly as a
186/// writer that names them would bind it ([`sources_at`], [`bind`]).
187///
188/// - `Ok(Some(_))`: every origin has a register entry that holds now. The
189///   prepared binding names the joined sources; the caller stores the text
190///   and then calls [`Carried::commit`].
191/// - `Ok(None)`: an origin has no register entry (never bound, evicted,
192///   expired) or one that names no file. The derived item is originless, as
193///   its origin is; nothing is inferred from a path the origin mentions.
194/// - `Err`, while protection is active: an origin is bound and is not served
195///   now, a source no longer admits, the origins belong to different
196///   projects, or the derived text cannot be bound (restricted, rewritten by
197///   admission). The derived item must NOT be stored — it would be an unbound
198///   copy of text whose source is withheld.
199///
200/// Without protection nothing is refused: what cannot be carried is
201/// originless, as every item is in that mode.
202pub(crate) fn carry(
203    derived: &Item<'_>,
204    text: &str,
205    origins: &[(Item<'_>, &str)],
206    reader_project: Option<&str>,
207) -> Result<Option<Carried>, Withheld> {
208    let enforced = stored::source_authority_required();
209    let mut project: Option<String> = None;
210    let mut sources = Vec::new();
211    let mut bound = !origins.is_empty();
212    for (origin, origin_text) in origins {
213        let Some(binding) = binding_of(origin, origin_text) else {
214            bound = false;
215            continue;
216        };
217        let held = held_sources(&binding, origin_text, reader_project).and_then(|held| {
218            let foreign = held.as_ref().is_some_and(|(root, _)| {
219                project
220                    .as_deref()
221                    .is_some_and(|joined| joined != root.as_str())
222            });
223            if foreign {
224                Err(Withheld::no_source_authority())
225            } else {
226                Ok(held)
227            }
228        });
229        match held {
230            Ok(Some((root, files))) => {
231                project = Some(root);
232                sources.extend(files);
233            }
234            Err(withheld) if enforced => return Err(withheld),
235            Ok(None) | Err(_) => bound = false,
236        }
237    }
238    let Some(project) = project.filter(|_| bound) else {
239        return Ok(None);
240    };
241    carried(derived, text, &project, sources, enforced)
242}
243
244/// The last step of [`carry`] and [`carry_from`]: the binding of the derived
245/// `text` to the sources its origins hold. While protection is active a text
246/// that cannot be bound to them is refused, never stored unbound.
247fn carried(
248    derived: &Item<'_>,
249    text: &str,
250    project: &str,
251    sources: Vec<SourceRef>,
252    enforced: bool,
253) -> Result<Option<Carried>, Withheld> {
254    match prepare(derived, text, project, sources) {
255        Some(binding) => Ok(Some(Carried(binding))),
256        None if enforced => Err(Withheld::no_source_authority()),
257        None => Ok(None),
258    }
259}
260
261/// [`carry`] for ONE origin whose binding is not kept in this register: a
262/// handoff ledger, whose binding lies beside the ledger file. `origin` is
263/// that binding and `origin_text` the text it binds. The derived item — a
264/// finding or fact restored from the ledger — is bound to EVERY file the
265/// origin names, read again now; the three outcomes are those of [`carry`].
266///
267/// This over-approximates per item: the origin has no per-item spans, so an
268/// item is bound to all the origin's files, not only to those its own text
269/// came from. More sources must hold, never fewer.
270pub(crate) fn carry_from(
271    derived: &Item<'_>,
272    text: &str,
273    origin: &StoredBinding,
274    origin_text: &str,
275    reader_project: Option<&str>,
276) -> Result<Option<Carried>, Withheld> {
277    let enforced = stored::source_authority_required();
278    let held = match held_sources(origin, origin_text, reader_project) {
279        Ok(held) => held,
280        Err(withheld) if enforced => return Err(withheld),
281        Err(_) => None,
282    };
283    let Some((project, sources)) = held else {
284        return Ok(None);
285    };
286    carried(derived, text, &project, sources, enforced)
287}
288
289/// What a store that COPIES `text` of `item` into another stored unit (a
290/// handoff ledger) has to know about it, without delivering it:
291///
292/// - `Ok(Some((project, sources)))`: the item is bound and holds now — served
293///   to `reader_project`, every file re-read against the digest it was bound
294///   to. The copy inherits exactly these sources;
295/// - `Ok(None)`: no register entry for this text, or one that names no file.
296///   The copy is originless, as the item is;
297/// - `Err`: the item is bound and is not served now, or a source no longer
298///   delivers what it was bound to. The caller decides by the protection
299///   state whether the text may be copied at all.
300///
301/// Nothing is published into the call's capture ([`serve`] does that): the
302/// re-reads go through the admitted reader, which publishes every read, so
303/// they run outside the capture. A check that ends in a refusal — or an item
304/// its caller then does not copy — leaves no source behind.
305pub fn held(
306    item: &Item<'_>,
307    text: &str,
308    reader_project: Option<&str>,
309) -> Result<Option<(String, Vec<SourceRef>)>, Withheld> {
310    let Some(binding) = binding_of(item, text) else {
311        return Ok(None);
312    };
313    crate::core::archive::authority::without_capture(|| {
314        held_sources(&binding, text, reader_project)
315    })
316}
317
318/// Whether `item`, bound in ANOTHER checkout of the same project, holds in
319/// the reader's `checkout` (AP01 §4, `content` class): every file its
320/// binding names, at the same path relative to the checkout it was bound
321/// in, delivers in `checkout` exactly the bytes it was bound to — read now
322/// through the admitted reader for `checkout`, under its policy and role.
323/// Held, those files of `checkout` are published into the call's capture,
324/// so the reply carries the lineage of the reader's own files.
325///
326/// `false` — the item must not be served there — for an item without a
327/// binding of exactly `text`, a binding that names no file, a path that
328/// does not lie in the bound checkout, and any file that is missing,
329/// changed (another branch or revision) or no longer admitted. Refused
330/// re-reads leave nothing in the capture.
331pub fn holds_in_checkout(item: &Item<'_>, text: &str, checkout: &str) -> bool {
332    use std::path::{Component, Path, PathBuf};
333    let Some(binding) = binding_of(item, text) else {
334        return false;
335    };
336    let Some((bound_root, files)) = binding.file_sources() else {
337        return false;
338    };
339    let canonical = |path: &str| crate::core::pathutil::safe_canonicalize_or_self(Path::new(path));
340    let bound_roots = [PathBuf::from(bound_root), canonical(bound_root)];
341    let relative = |path: &str| -> Option<PathBuf> {
342        let candidates = [PathBuf::from(path), canonical(path)];
343        let relative = candidates.iter().find_map(|candidate| {
344            bound_roots
345                .iter()
346                .find_map(|root| candidate.strip_prefix(root).ok())
347        })?;
348        relative
349            .components()
350            .all(|part| matches!(part, Component::Normal(_)))
351            .then(|| relative.to_path_buf())
352    };
353    let checkout_root = canonical(checkout);
354    let Some(checkout_str) = checkout_root.to_str() else {
355        return false;
356    };
357    let held = crate::core::archive::authority::without_capture(|| {
358        files
359            .iter()
360            .map(|&(path, tool, digest)| {
361                let relative = relative(path)?;
362                let target = checkout_root.join(&relative);
363                SourceRef::read_as_admitted(target.to_str()?, checkout_str, tool, digest)?;
364                Some((relative, tool, digest))
365            })
366            .collect::<Option<Vec<_>>>()
367    });
368    let Some(held) = held else {
369        return false;
370    };
371    for (relative, tool, digest) in held {
372        if let Some(source) = ArchiveAuthority::admitted_source(
373            &checkout_root,
374            relative.to_str().unwrap_or_default(),
375            tool,
376            digest,
377        ) {
378            source.publish();
379        }
380    }
381    true
382}
383
384/// The binding of `item` if it is a binding of exactly `text`.
385fn binding_of(item: &Item<'_>, text: &str) -> Option<StoredBinding> {
386    REGISTER
387        .read::<StoredBinding>(&item.key(text), register::now())
388        .filter(|binding| binding.binds(text))
389}
390
391/// For the store that is about to deliver `text` of `item` to a reader in
392/// `reader_project`, while protection is active:
393///
394/// - a bound item is re-authorized (`stored::serve_stored`: project, every
395///   source re-read, classification, content rules). `Err`: the item must NOT
396///   be delivered — a bound item is as revocable as its sources. `Ok`: its
397///   files are published into the call's source capture;
398/// - an originless item marks the capture incomplete and returns `Ok`: whether
399///   it is delivered stays the decision of its store, as before.
400///
401/// Without protection nothing is checked or published.
402pub fn serve(item: &Item<'_>, text: &str, reader_project: Option<&str>) -> Result<(), Withheld> {
403    if !stored::source_authority_required() {
404        return Ok(());
405    }
406    let binding = binding_of(item, text);
407    if let Some(binding) = &binding {
408        stored::serve_stored(text, Some(binding), reader_project)?;
409    }
410    stored::publish_served(binding.as_ref());
411    Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::core::archive::delivered::LineageRefusal;
418    use crate::core::context_admission::stored::lineage_support::{
419        deliver, governed, project, reusable,
420    };
421    use crate::core::policy::runtime::TestPolicyOverride;
422
423    fn fact<'a>(project: &'a str, key: &'a str) -> Item<'a> {
424        Item::KnowledgeFact {
425            project,
426            category: "architecture",
427            key,
428        }
429    }
430
431    /// Deliver one fact the way a recall formats it.
432    fn recall(root: &str, item: &Item<'_>, value: &str) -> (String, usize) {
433        deliver(root, || match serve(item, value, Some(root)) {
434            Ok(()) => format!("[architecture] {value}"),
435            Err(withheld) => withheld.notice(),
436        })
437    }
438
439    /// A fact written with a source the admitted reader returned is bound,
440    /// its delivery is registered and fails with the source; the bound fact
441    /// itself is then withheld. A fact written without a source, and one
442    /// whose value was replaced after the binding, are originless.
443    #[test]
444    fn a_fact_is_bound_to_the_sources_named_at_its_write_only() {
445        let _data = governed();
446        let _community = TestPolicyOverride::set(None);
447        let (_directory, root) = project();
448        std::fs::write(format!("{root}/auth.rs"), "pub fn verify_rs256() {}\n").expect("source");
449        let value = "tokens are verified with RS256";
450        let bound_fact = fact(&root, "token-check");
451        let sources = sources_at(&root, &["auth.rs"], "ctx_knowledge").expect("readable source");
452        assert!(bind(&bound_fact, value, &root, sources));
453
454        let (reply, recorded) = recall(&root, &bound_fact, value);
455        assert!(reply.contains("RS256"), "{reply}");
456        assert_eq!(recorded, 1);
457        assert_eq!(reusable(&root, &reply), Ok(()));
458
459        // No source named: nothing is bound, the delivery is unknown.
460        let plain = fact(&root, "typed-note");
461        assert!(sources_at::<&str>(&root, &[], "ctx_knowledge").is_none());
462        assert!(!bind(&plain, "typed by the agent", &root, Vec::new()));
463        let (typed, recorded) = recall(&root, &plain, "typed by the agent");
464        assert!(typed.contains("typed by the agent"), "{typed}");
465        assert_eq!(recorded, 0);
466        assert_eq!(reusable(&root, &typed), Err(LineageRefusal::Unknown));
467
468        // The value is replaced without sources: the old binding is not a
469        // binding of the new text.
470        let (replaced, recorded) = recall(&root, &bound_fact, "tokens are opaque now");
471        assert!(replaced.contains("opaque"), "{replaced}");
472        assert_eq!(recorded, 0);
473
474        // A named source that cannot be bound binds nothing (E3, and never a
475        // partial lineage).
476        std::fs::write(format!("{root}/plan.md"), "*** TOP SECRET ***\nlaunch\n").expect("marked");
477        assert!(sources_at(&root, &["auth.rs", "plan.md"], "ctx_knowledge").is_none());
478        assert!(sources_at(&root, &["missing.rs"], "ctx_knowledge").is_none());
479        assert!(sources_at(&root, &["../outside.rs"], "ctx_knowledge").is_none());
480
481        // The source goes: the registered reply is refused, and the bound
482        // fact is no longer served.
483        std::fs::remove_file(format!("{root}/auth.rs")).expect("delete");
484        assert_eq!(
485            reusable(&root, &reply),
486            Err(LineageRefusal::SourceNoLongerAdmitted)
487        );
488        let (withheld, recorded) = recall(&root, &bound_fact, value);
489        assert!(!withheld.contains("RS256"), "{withheld}");
490        assert_eq!(recorded, 0);
491    }
492
493    /// `held` is a check, never a delivery. Its re-reads went through the
494    /// publishing reader inside the call's capture: an item that held left
495    /// its files there even when the next one was refused (or when its caller
496    /// did not copy it), so a refusal carried a file lineage.
497    #[test]
498    fn a_held_check_leaves_the_capture_untouched() {
499        use crate::core::archive::authority::capture_sync;
500        use crate::core::context_admission::stored::lineage_support::scoped;
501        let _data = governed();
502        let _community = TestPolicyOverride::set(None);
503        let (_directory, root) = project();
504        for name in ["auth.rs", "pool.rs"] {
505            std::fs::write(format!("{root}/{name}"), format!("pub fn {name}() {{}}\n"))
506                .expect("source");
507        }
508        let (holding, lapsed) = (fact(&root, "token-check"), fact(&root, "pool-size"));
509        for (item, value, file) in [
510            (&holding, "tokens are verified with RS256", "auth.rs"),
511            (&lapsed, "the pool holds eight leases", "pool.rs"),
512        ] {
513            let sources = sources_at(&root, &[file], "ctx_knowledge").expect("readable source");
514            assert!(bind(item, value, &root, sources));
515        }
516        std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
517
518        let ((first, second), capture) = scoped(&root, || {
519            capture_sync(|| {
520                (
521                    held(&holding, "tokens are verified with RS256", Some(&root)),
522                    held(&lapsed, "the pool holds eight leases", Some(&root)),
523                )
524            })
525        });
526        let (project, sources) = first.expect("holds").expect("bound to a file");
527        assert_eq!(project, root);
528        assert_eq!(sources.len(), 1);
529        assert!(second.is_err(), "its source is gone");
530        assert!(
531            capture.is_none(),
532            "a check publishes nothing, held or refused"
533        );
534    }
535
536    /// A store written before bindings existed has no register entry: its
537    /// items are originless, and nothing is on disk for them.
538    #[test]
539    fn an_item_without_a_register_entry_is_originless() {
540        let data = governed();
541        let _community = TestPolicyOverride::set(None);
542        let (_directory, root) = project();
543        let finding = Item::SessionFinding {
544            session: "session-1",
545            file: Some("src/lib.rs"),
546        };
547        let (reply, recorded) = deliver(&root, || {
548            serve(&finding, "an old finding", Some(&root))
549                .expect("originless items are not refused");
550            "finding: an old finding".to_owned()
551        });
552        assert_eq!(recorded, 0);
553        assert_eq!(reusable(&root, &reply), Err(LineageRefusal::Unknown));
554        assert!(!data.path().join("archives").join("bindings").exists());
555    }
556
557    /// A finding derived from the output of a call is bound to that call's
558    /// complete source set.
559    #[test]
560    fn a_finding_is_bound_to_the_sources_of_the_call_that_produced_it() {
561        let _data = governed();
562        let _community = TestPolicyOverride::set(None);
563        let (_directory, root) = project();
564        let path = format!("{root}/build.rs");
565        std::fs::write(&path, "fn main() { panic!(\"linker\") }\n").expect("source");
566        let admitted =
567            crate::core::rooted_read::read_file_for_tool_rooted(&path, &root, "ctx_read")
568                .expect("admitted read");
569        // What the admitted reader publishes into the call's capture.
570        let capture =
571            ArchiveAuthority::file(std::path::Path::new(&root), &path, "ctx_read", &admitted)
572                .expect("protection is active: the read is bound");
573        let finding = Item::SessionFinding {
574            session: "session-1",
575            file: Some("build.rs"),
576        };
577        let summary = "build.rs panics in the linker step";
578        let sources = sources_of_call(&capture).expect("complete source set");
579        assert!(bind(&finding, summary, &root, sources));
580        let (reply, recorded) = deliver(&root, || {
581            serve(&finding, summary, Some(&root)).expect("bound and admitted");
582            format!("finding: {summary}")
583        });
584        assert_eq!(recorded, 1);
585        assert_eq!(reusable(&root, &reply), Ok(()));
586
587        std::fs::write(&path, "fn main() {}\n").expect("the source changes");
588        assert_eq!(
589            reusable(&root, &reply),
590            Err(LineageRefusal::SourceNoLongerAdmitted)
591        );
592    }
593}