1use 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#[derive(Debug, Clone, Copy)]
32pub enum Item<'a> {
33 KnowledgeFact {
35 project: &'a str,
36 category: &'a str,
37 key: &'a str,
38 },
39 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 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
79pub 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
99pub(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
114pub 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
123pub(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
144pub struct Carried(StoredBinding);
147
148impl Carried {
149 pub fn commit(self, item: &Item<'_>, text: &str) -> bool {
154 self.0.binds(text) && write(item, text, self.0)
155 }
156}
157
158fn 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
181pub(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
244fn 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
261pub(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
289pub 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
318pub 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
384fn 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
391pub 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 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 #[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 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 let (replaced, recorded) = recall(&root, &bound_fact, "tokens are opaque now");
471 assert!(replaced.contains("opaque"), "{replaced}");
472 assert_eq!(recorded, 0);
473
474 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 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 #[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 #[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 #[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 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}