1use std::{
6 collections::{BTreeMap, HashMap},
7 path::Path,
8};
9
10use chrono::{DateTime, SecondsFormat, Timelike, Utc};
11use lean_ctx_protocol::context_gateway::ClassificationV1;
12use lean_ctx_protocol::{
13 ContextDispositionV1, DataClassification, EngineContextSourceDescriptorV1,
14 EngineContextSourcePermissionV1, EngineContextSourcePlanRequestV1,
15 EngineContextSourcePlanResponseV1, EngineContextSourceV1, UtcTimestamp,
16};
17
18use crate::core::{
19 context_field::{ContextField, ContextItemId, FieldWeights, Provenance, ViewCosts, ViewKind},
20 context_kernel::{
21 autopilot::{AutopilotController, TaskAutopilotDecision},
22 orchestrator::ContextKernel,
23 policy::ContextPolicy,
24 types::{
25 CandidateProvider, ContextObjectKind, ContextObjectV1, Freshness, RetrievalContext,
26 SensitivityLevel, SideEffectPolicy,
27 },
28 },
29};
30
31#[derive(Clone)]
32struct ExplicitSourceProvider(Vec<ContextObjectV1>);
33
34impl CandidateProvider for ExplicitSourceProvider {
35 fn provider_id(&self) -> &'static str {
36 "engine.explicit-sources.v1"
37 }
38
39 fn candidates(&self, ctx: &RetrievalContext) -> Vec<ContextObjectV1> {
40 self.0.iter().take(ctx.max_candidates).cloned().collect()
41 }
42
43 fn side_effect_policy(&self) -> SideEffectPolicy {
44 SideEffectPolicy::ReadOnly
45 }
46}
47
48pub fn plan(
49 root: &Path,
50 request: EngineContextSourcePlanRequestV1,
51) -> Result<EngineContextSourcePlanResponseV1, &'static str> {
52 request
53 .validate_payload()
54 .map_err(|_| "invalid_source_manifest")?;
55 ensure_project_root_matches_scope(root)?;
56 let policy = current_policy(root)?;
57 let (evaluation_time, include_marker) = if policy.retention_days.is_some() {
58 (
59 Utc::now()
60 .with_nanosecond(0)
61 .ok_or("source_plan_epoch_invalid")?
62 .fixed_offset(),
63 true,
64 )
65 } else {
66 (Utc::now().fixed_offset(), false)
67 };
68 Ok(plan_at(root, request, evaluation_time, include_marker)?.response)
69}
70
71pub(super) struct SourcePlanningHandoff {
73 pub(super) decision: TaskAutopilotDecision,
74 pub(super) response: EngineContextSourcePlanResponseV1,
75 pub(super) admitted_sources: Vec<AdmittedSourceBody>,
76}
77
78pub(super) struct AdmittedSourceBody {
82 pub(super) descriptor: EngineContextSourceDescriptorV1,
83 pub(super) candidate: ContextObjectV1,
84}
85
86pub(super) fn plan_for_materialization(
91 root: &Path,
92 request: EngineContextSourcePlanRequestV1,
93 evaluation_time: Option<DateTime<chrono::FixedOffset>>,
94) -> Result<SourcePlanningHandoff, &'static str> {
95 request
96 .validate_payload()
97 .map_err(|_| "invalid_source_manifest")?;
98 ensure_project_root_matches_scope(root)?;
99 let policy = current_policy(root)?;
100 let include_marker = policy.retention_days.is_some();
101 let evaluation_time = if include_marker {
102 let evaluation_time = evaluation_time.ok_or("source_plan_epoch_missing")?;
103 if evaluation_time > Utc::now().fixed_offset() {
104 return Err("source_plan_epoch_invalid");
105 }
106 evaluation_time
107 } else {
108 Utc::now().fixed_offset()
111 };
112 plan_at(root, request, evaluation_time, include_marker)
113}
114
115fn plan_at(
116 root: &Path,
117 request: EngineContextSourcePlanRequestV1,
118 evaluation_time: DateTime<chrono::FixedOffset>,
119 include_marker: bool,
120) -> Result<SourcePlanningHandoff, &'static str> {
121 request
122 .validate_payload()
123 .map_err(|_| "invalid_source_manifest")?;
124 ensure_project_root_matches_scope(root)?;
125 let project_root = root.to_str().ok_or("unsafe_root")?;
126 crate::core::policy::runtime::with_project_source_view(project_root, || {
127 plan_at_in_view(root, request, evaluation_time, include_marker)
128 })
129 .map_err(|_| "source_policy_view_unavailable")?
130}
131
132fn plan_at_in_view(
133 root: &Path,
134 request: EngineContextSourcePlanRequestV1,
135 evaluation_time: DateTime<chrono::FixedOffset>,
136 include_marker: bool,
137) -> Result<SourcePlanningHandoff, &'static str> {
138 let now = Utc::now();
139 let max_sensitivity = current_policy(root)?.max_sensitivity;
140 let mut candidates = request
141 .sources
142 .iter()
143 .map(|source| candidate(source, now, &max_sensitivity))
144 .collect::<Result<Vec<_>, _>>()?
145 .into_iter()
146 .flatten()
147 .collect::<Vec<_>>();
148 candidates.sort_by(|left, right| left.id.as_str().cmp(right.id.as_str()));
150 let admitted_candidate_by_ref = candidates
151 .iter()
152 .map(|candidate| (candidate.id.0.clone(), candidate.clone()))
153 .collect::<HashMap<_, _>>();
154 let provider = ExplicitSourceProvider(candidates);
155 let controller = AutopilotController::with_kernels(
156 ContextKernel::with_field(
157 vec![Box::new(provider.clone())],
158 ContextField::with_weights(FieldWeights::default()),
159 )
160 .with_relevance_floor(),
161 ContextKernel::with_field(
162 vec![Box::new(provider)],
163 ContextField::with_weights(FieldWeights::default()),
164 )
165 .with_relevance_floor(),
166 );
167 let decision = super::plan_with_controller_at(
168 root,
169 request.planning,
170 Some(controller),
171 Some(evaluation_time),
172 )?;
173 let initial_result = super::response(&decision)?;
174 let epoch = if include_marker {
175 Some(
176 UtcTimestamp::new(evaluation_time.to_rfc3339_opts(SecondsFormat::Secs, true))
177 .map_err(|_| "source_plan_epoch_invalid")?,
178 )
179 } else {
180 None
181 };
182 let mut by_digest = BTreeMap::<&str, Vec<&EngineContextSourceDescriptorV1>>::new();
185 for source in &request.sources {
186 let descriptor = &source.descriptor;
187 if let Some([origin]) = decision
188 .decision()
189 .context_plan
190 .origins
191 .get(descriptor.object_ref.as_str())
192 .map(Vec::as_slice)
193 && origin.admitted
194 && origin.source == descriptor.source_id.as_str()
195 && origin.content_ref == descriptor.content_digest.as_str()
196 {
197 by_digest
198 .entry(descriptor.content_digest.as_str())
199 .or_default()
200 .push(descriptor);
201 }
202 }
203 let mut groups = Vec::new();
204 for selection in &initial_result.plan.selections {
205 if selection.disposition != ContextDispositionV1::Selected {
206 continue;
207 }
208 if let Some(equivalent) = selection
209 .sha256_digest
210 .as_deref()
211 .and_then(|digest| by_digest.get_mut(digest))
212 && equivalent.len() > 1
213 {
214 equivalent.sort_by(|left, right| left.object_ref.cmp(&right.object_ref));
215 groups.push(serde_json::json!({
216 "selected_ref": selection.source_ref,
217 "equivalent_sources": equivalent,
218 }));
219 }
220 }
221 let lineage = (!groups.is_empty()).then(|| {
222 serde_json::json!({
223 "schema_version": 1, "groups": groups,
224 })
225 });
226 let source_bindings = request
227 .sources
228 .iter()
229 .filter(|source| {
230 initial_result.plan.selections.iter().any(|selection| {
231 selection.source_ref == source.descriptor.object_ref.as_str()
232 && selection.disposition == ContextDispositionV1::Selected
233 })
234 })
235 .map(|source| source.descriptor.clone())
236 .collect::<Vec<_>>();
237 let admitted_sources = source_bindings
238 .iter()
239 .map(|descriptor| {
240 let candidate = admitted_candidate_by_ref
241 .get(descriptor.object_ref.as_str())
242 .ok_or("source_plan_changed")?;
243 Ok(AdmittedSourceBody {
244 descriptor: descriptor.clone(),
245 candidate: candidate.clone(),
246 })
247 })
248 .collect::<Result<Vec<_>, &'static str>>()?;
249 let admission_metadata = source_admission_metadata(&admitted_sources)?;
250 let decision = decision
255 .bind_source_metadata(lineage, epoch, None)
256 .map_err(|_| "source_lineage_too_large")?
257 .bind_source_metadata(None, None, Some(admission_metadata))
258 .map_err(|_| "source_plan_metadata_too_large")?;
259 let result = super::response(&decision)?;
260 ensure_current_admission(
263 root,
264 &request.sources,
265 &source_bindings,
266 &admitted_sources,
267 result.plan.budget_tokens,
268 )?;
269 let response =
270 EngineContextSourcePlanResponseV1::new(result, source_bindings).map_err(|_| "internal")?;
271 Ok(SourcePlanningHandoff {
275 decision,
276 response,
277 admitted_sources,
278 })
279}
280
281fn current_policy(root: &Path) -> Result<ContextPolicy, &'static str> {
282 let root = root.to_str().ok_or("unsafe_root")?;
283 ContextPolicy::from_config(root).map_err(|_| "context_policy_unavailable")
284}
285
286pub(crate) fn kernel_policy_identity(root: &Path) -> Option<String> {
290 serde_json::to_string(¤t_policy(root).ok()?).ok()
291}
292
293pub(super) fn presented_bindings(
298 root: &Path,
299 admitted_sources: &[AdmittedSourceBody],
300) -> Option<Vec<crate::core::archive::presented::PresentedBinding>> {
301 if admitted_sources.is_empty() {
302 return None;
303 }
304 admitted_sources
305 .iter()
306 .map(|source| {
307 if source.candidate.sensitivity == SensitivityLevel::Restricted {
308 return None;
309 }
310 crate::core::archive::presented::PresentedBinding::admitted_now(
311 root,
312 source.descriptor.object_ref.as_str(),
313 source.descriptor.source_id.as_str(),
314 source.descriptor.content_digest.as_str(),
315 )
316 })
317 .collect()
318}
319
320fn record_presentations(root: &Path, admitted_sources: &[AdmittedSourceBody]) {
324 for source in admitted_sources {
325 if source.candidate.sensitivity == SensitivityLevel::Restricted {
326 continue;
327 }
328 if let Some(binding) = crate::core::archive::presented::PresentedBinding::admitted_now(
329 root,
330 source.descriptor.object_ref.as_str(),
331 source.descriptor.source_id.as_str(),
332 source.descriptor.content_digest.as_str(),
333 ) {
334 crate::core::archive::presented::record_presentation(&binding);
335 }
336 }
337}
338
339fn ensure_project_root_matches_scope(root: &Path) -> Result<(), &'static str> {
340 let request_root = crate::core::policy::runtime::detached_project_root(root)
341 .map_err(|_| "source_policy_view_unavailable")?;
342 if request_root != root {
343 let supplied = root
344 .canonicalize()
345 .map_err(|_| "source_policy_view_unavailable")?;
346 let requested = request_root
347 .canonicalize()
348 .map_err(|_| "source_policy_view_unavailable")?;
349 if supplied != requested {
350 return Err("source_policy_view_mismatch");
351 }
352 }
353 Ok(())
354}
355
356fn source_admission_metadata(
357 sources: &[AdmittedSourceBody],
358) -> Result<serde_json::Value, &'static str> {
359 let mut records = sources
360 .iter()
361 .map(|source| {
362 let content = source
363 .candidate
364 .content
365 .as_deref()
366 .ok_or("source_plan_changed")?;
367 let admitted_digest = super::super::sha256_digest(content.as_bytes())
368 .map_err(|_| "source_plan_digest_failed")?;
369 Ok(serde_json::json!({
370 "object_ref": source.descriptor.object_ref.as_str(),
371 "source_id": source.descriptor.source_id.as_str(),
372 "original_digest": source.descriptor.content_digest.as_str(),
373 "admitted_digest": admitted_digest.as_str(),
374 "effective_classification": classification_name(&source.candidate.sensitivity),
375 }))
376 })
377 .collect::<Result<Vec<_>, &'static str>>()?;
378 records.sort_by(|left, right| {
379 left["object_ref"]
380 .as_str()
381 .cmp(&right["object_ref"].as_str())
382 });
383 Ok(serde_json::json!({
384 "schema_version": 1,
385 "sources": records,
386 }))
387}
388
389fn classification_name(sensitivity: &SensitivityLevel) -> &'static str {
390 match sensitivity {
391 SensitivityLevel::Public => "public",
392 SensitivityLevel::Internal => "internal",
393 SensitivityLevel::Confidential => "confidential",
394 SensitivityLevel::Restricted => "restricted",
395 }
396}
397
398pub(super) fn ensure_current_admission(
399 root: &Path,
400 sources: &[EngineContextSourceV1],
401 bindings: &[EngineContextSourceDescriptorV1],
402 admitted_sources: &[AdmittedSourceBody],
403 budget_tokens: u64,
404) -> Result<(), &'static str> {
405 let policy = current_policy(root)?;
406 if let Some(cap) = policy.budget_cap_tokens
407 && budget_tokens > u64::try_from(cap).map_err(|_| "context_policy_unavailable")?
408 {
409 return Err("source_plan_changed");
410 }
411 let now = Utc::now();
412 let evaluation_time = now.fixed_offset();
413 for binding in bindings {
414 sources
415 .iter()
416 .find(|source| source.descriptor == *binding)
417 .ok_or("source_plan_changed")?;
418 let object = admitted_sources
419 .iter()
420 .find(|source| source.descriptor == *binding)
421 .map(|source| &source.candidate)
422 .ok_or("source_plan_changed")?;
423 if policy
424 .candidate_violation_at(&object, Some(&evaluation_time))
425 .is_some()
426 {
427 return Err("source_plan_changed");
428 }
429 }
430 Ok(())
431}
432
433pub(super) fn revalidate_admitted_sources(
434 root: &Path,
435 sources: &[EngineContextSourceV1],
436 bindings: &[EngineContextSourceDescriptorV1],
437 admitted_sources: &[AdmittedSourceBody],
438 budget_tokens: u64,
439) -> Result<(), &'static str> {
440 ensure_project_root_matches_scope(root)?;
441 let project_root = root.to_str().ok_or("unsafe_root")?;
442 crate::core::policy::runtime::with_project_source_view(project_root, || {
443 let policy = current_policy(root)?;
444 if let Some(cap) = policy.budget_cap_tokens
445 && budget_tokens > u64::try_from(cap).map_err(|_| "context_policy_unavailable")?
446 {
447 return Err("source_plan_changed");
448 }
449 let now = Utc::now();
450 let evaluation_time = now.fixed_offset();
451 let mut revalidated = Vec::with_capacity(bindings.len());
452 for binding in bindings {
453 let original = sources
454 .iter()
455 .find(|source| source.descriptor == *binding)
456 .ok_or("source_plan_changed")?;
457 let retained = admitted_sources
458 .iter()
459 .find(|source| source.descriptor == *binding)
460 .ok_or("source_plan_changed")?;
461 let current =
462 candidate(original, now, &policy.max_sensitivity)?.ok_or("source_plan_changed")?;
463 if current.content != retained.candidate.content
464 || current.sensitivity != retained.candidate.sensitivity
465 || policy
466 .candidate_violation_at(¤t, Some(&evaluation_time))
467 .is_some()
468 {
469 return Err("source_plan_changed");
470 }
471 revalidated.push(AdmittedSourceBody {
472 descriptor: binding.clone(),
473 candidate: current,
474 });
475 }
476 record_presentations(root, &revalidated);
478 Ok(())
479 })
480 .map_err(|_| "source_policy_view_unavailable")?
481}
482
483pub fn candidate(
496 source: &EngineContextSourceV1,
497 now: DateTime<Utc>,
498 max_sensitivity: &SensitivityLevel,
499) -> Result<Option<ContextObjectV1>, &'static str> {
500 let descriptor = &source.descriptor;
501 if descriptor.permission != EngineContextSourcePermissionV1::Permitted {
502 return Ok(None);
503 }
504 let declared_classification = match descriptor.classification.as_ref() {
505 Some(DataClassification::Public) => ClassificationV1::Public,
506 Some(DataClassification::Internal) => ClassificationV1::Internal,
507 Some(DataClassification::Confidential) => ClassificationV1::Confidential,
508 Some(DataClassification::Restricted) => ClassificationV1::Restricted,
509 None => return Ok(None),
510 };
511 for (timestamp, expiry) in [
512 (&descriptor.observed_at, false),
513 (&descriptor.valid_until, true),
514 ] {
515 if let Some(timestamp) = timestamp {
516 let time = DateTime::parse_from_rfc3339(timestamp.as_str())
517 .map_err(|_| "invalid_source_manifest")?;
518 if (expiry && time <= now) || (!expiry && time > now) {
519 return Ok(None);
520 }
521 }
522 }
523 let admission = match crate::core::context_admission::admit_context_source(&source.content) {
524 Ok(admission) => admission,
525 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok(None),
528 Err(_) => return Err("source_admission_unavailable"),
529 };
530 let admitted_maximum = match max_sensitivity {
531 SensitivityLevel::Public => ClassificationV1::Public,
532 SensitivityLevel::Internal => ClassificationV1::Internal,
533 SensitivityLevel::Confidential => ClassificationV1::Confidential,
534 SensitivityLevel::Restricted => ClassificationV1::Restricted,
535 };
536 let unmarked = crate::core::config::Config::load_arc()
539 .context_gateway
540 .mode
541 .unclassified();
542 let detected = admission.classification;
543 if detected > unmarked && detected > declared_classification && detected > admitted_maximum {
544 return Ok(None);
545 }
546 let classification = declared_classification.join(admission.classification);
547 let sensitivity = match classification {
548 ClassificationV1::Public => SensitivityLevel::Public,
549 ClassificationV1::Internal => SensitivityLevel::Internal,
550 ClassificationV1::Confidential => SensitivityLevel::Confidential,
551 ClassificationV1::Restricted => SensitivityLevel::Restricted,
552 };
553 let tokens = crate::core::tokens::count_tokens(&admission.text).max(1);
554 let mut view_costs = ViewCosts::new();
556 view_costs.set(ViewKind::Full, tokens);
557 let observed = descriptor
558 .observed_at
559 .as_ref()
560 .map(|time| time.as_str().to_owned());
561 let mut metadata = HashMap::new();
562 metadata.insert(
563 "source_binding".to_owned(),
564 serde_json::to_string(descriptor).map_err(|_| "invalid_source_manifest")?,
565 );
566 Ok(Some(ContextObjectV1 {
567 id: ContextItemId(descriptor.object_ref.as_str().to_owned()),
568 kind: ContextObjectKind::SearchChunk,
569 source: descriptor.source_id.as_str().to_owned(),
570 content_ref: descriptor.content_digest.as_str().to_owned(),
571 title: descriptor.object_ref.as_str().to_owned(),
572 content: Some(admission.text),
573 freshness: Freshness {
574 created_at: observed.clone().unwrap_or_default(),
575 ttl_secs: None,
576 stale: observed.is_none(),
577 },
578 confidence: 0.0,
580 sensitivity,
581 token_estimate: tokens,
582 view_costs,
583 provenance: Provenance {
584 tool: Some("engine.explicit-sources.v1".to_owned()),
585 timestamp: observed,
586 ..Provenance::default()
587 },
588 semantic_fingerprint: None,
589 metadata,
590 }))
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 fn source(content: &str, declared: &str) -> EngineContextSourceV1 {
598 let digest = super::super::super::sha256_digest(content.as_bytes()).expect("digest");
599 serde_json::from_value(serde_json::json!({
600 "descriptor": {
601 "object_ref": "marked", "source_id": "source-marked",
602 "source_type": "filesystem", "content_digest": digest.as_str(),
603 "revision": "revision-one", "owner": "operator",
604 "observed_at": null, "valid_until": null,
605 "classification": declared, "permission": "permitted"
606 },
607 "content": content
608 }))
609 .expect("source fixture")
610 }
611
612 #[test]
616 fn a_detected_classification_above_declaration_and_policy_is_no_candidate() {
617 let _data = crate::core::data_dir::isolated_data_dir();
618 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
619 let dir = crate::core::paths::config_dir_read_only().expect("config dir");
620 std::fs::create_dir_all(&dir).expect("config dir");
621 std::fs::write(
622 dir.join("config.toml"),
623 "[context_gateway]\nclassification = \"warn\"\n",
624 )
625 .expect("config");
626 let now = Utc::now();
627 let marked = "*** TOP SECRET *** invoice ledger plans";
628
629 let laundered = candidate(&source(marked, "Public"), now, &SensitivityLevel::Internal)
632 .expect("no transport error");
633 assert!(laundered.is_none(), "old code returned a candidate");
634
635 let admitted = candidate(
638 &source(marked, "Public"),
639 now,
640 &SensitivityLevel::Restricted,
641 )
642 .expect("no transport error")
643 .expect("candidate");
644 assert_eq!(admitted.sensitivity, SensitivityLevel::Restricted);
645
646 let declared = candidate(
649 &source("invoice ledger plans", "Restricted"),
650 now,
651 &SensitivityLevel::Internal,
652 )
653 .expect("no transport error")
654 .expect("candidate for the canonical exclusion");
655 assert_eq!(declared.sensitivity, SensitivityLevel::Restricted);
656
657 let plain = candidate(
659 &source("invoice ledger plans", "Public"),
660 now,
661 &SensitivityLevel::Internal,
662 )
663 .expect("no transport error")
664 .expect("candidate");
665 assert_eq!(plain.sensitivity, SensitivityLevel::Public);
666 assert_eq!(plain.content.as_deref(), Some("invoice ledger plans"));
667 }
668}