Skip to main content

lean_ctx/core/providers/
provenance.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Durable source dependencies. These are references, never authorization grants.
3
4use serde::{Deserialize, Serialize};
5
6use super::provider_trait::ProviderParams;
7use super::{ProviderItem, ProviderResult};
8
9#[cfg(test)]
10#[path = "provenance_tests.rs"]
11mod tests;
12
13/// Why a stored provider item may not be reused now, in the source-validity
14/// vocabulary of the context decision contract (doc 19): revoked (no right to
15/// read it any more), deleted (gone from the source), stale (another revision
16/// now), unavailable (the source did not answer), unsupported (no item
17/// re-check possible), revalidate_required (the authority or policy it was
18/// checked under changed). A provider that answers "not found" for both a
19/// deleted item and one the reader may not see is reported as revoked: the
20/// gateway never tells the two apart for the caller.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub(crate) struct ReuseRefusal {
23    pub(crate) validity: lean_ctx_protocol::context_lineage::SourceValidityV1,
24    pub(crate) reason: String,
25}
26
27impl ReuseRefusal {
28    fn new(
29        validity: lean_ctx_protocol::context_lineage::SourceValidityV1,
30        reason: impl Into<String>,
31    ) -> Self {
32        Self {
33            validity,
34            reason: reason.into(),
35        }
36    }
37
38    /// The provider's own error for a re-acquisition, classified: an access
39    /// refusal or a not-found is revoked, a missing item re-check is
40    /// unsupported, everything else (timeouts, rate limits, outages) is
41    /// unavailable.
42    fn of_reacquisition(error: String) -> Self {
43        use lean_ctx_protocol::context_lineage::SourceValidityV1 as V;
44        let lower = error.to_ascii_lowercase();
45        let access_status = lower
46            .split(|c: char| !c.is_ascii_digit())
47            .any(|number| matches!(number, "401" | "403" | "404" | "410"));
48        let validity = if lower.contains("does not support current item authorization") {
49            V::Unsupported
50        } else if access_status || lower.contains("forbidden") || lower.contains("not found") {
51            V::Revoked
52        } else {
53            V::Unavailable
54        };
55        Self::new(validity, error)
56    }
57}
58
59impl std::fmt::Display for ReuseRefusal {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.write_str(&self.reason)
62    }
63}
64
65impl From<ReuseRefusal> for String {
66    fn from(refusal: ReuseRefusal) -> Self {
67        refusal.reason
68    }
69}
70
71tokio::task_local! {
72    static REUSE_DEADLINE: std::time::Instant;
73    static REUSE_CHECKS: std::cell::Cell<usize>;
74}
75
76pub(crate) fn with_reuse_deadline<T>(operation: impl FnOnce() -> T) -> T {
77    with_reuse_deadline_until(None, operation)
78}
79
80/// [`with_reuse_deadline`] for a caller with a deadline of its own: the
81/// operation ends at the earlier of `outer` and the deadline that would
82/// apply without it (the enclosing scope's, or five seconds from now). It
83/// only ever shortens; `None` is exactly [`with_reuse_deadline`]. Inside an
84/// enclosing scope the check budget stays the enclosing one.
85pub(crate) fn with_reuse_deadline_until<T>(
86    outer: Option<std::time::Instant>,
87    operation: impl FnOnce() -> T,
88) -> T {
89    if let Ok(enclosing) = REUSE_DEADLINE.try_with(|end| *end) {
90        return match outer {
91            Some(outer) if outer < enclosing => REUSE_DEADLINE.sync_scope(outer, operation),
92            _ => operation(),
93        };
94    }
95    let own = std::time::Instant::now() + std::time::Duration::from_secs(5);
96    let end = outer.map_or(own, |outer| outer.min(own));
97    REUSE_CHECKS.sync_scope(std::cell::Cell::new(32), || {
98        REUSE_DEADLINE.sync_scope(end, operation)
99    })
100}
101
102fn reserve_reuse_check() -> Result<(), String> {
103    REUSE_CHECKS
104        .try_with(|remaining| {
105            let next = remaining
106                .get()
107                .checked_sub(1)
108                .ok_or("provider reuse check budget exhausted")?;
109            remaining.set(next);
110            Ok(())
111        })
112        .map_err(|_| "provider reuse check scope is unavailable")?
113}
114
115/// Applied to the complete HTTP transaction, including its response body.
116pub(crate) fn reuse_time_remaining() -> Option<std::time::Duration> {
117    REUSE_DEADLINE
118        .try_with(|end| end.saturating_duration_since(std::time::Instant::now()))
119        .ok()
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(deny_unknown_fields)]
124pub struct ProviderOrigin {
125    pub version: u16,
126    pub provider: String,
127    pub resource: String,
128    pub binding: String,
129    pub params: ProviderParams,
130    pub item_id: String,
131    pub item_digest: String,
132    #[serde(default)]
133    pub projection: Projection,
134}
135
136#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
137#[serde(rename_all = "snake_case")]
138pub enum Projection {
139    #[default]
140    Raw,
141    SnapshotV1,
142}
143
144impl Projection {
145    fn item(self, item: &ProviderItem) -> Result<ProviderItem, String> {
146        if self == Self::Raw {
147            return Ok(item.clone());
148        }
149        let mut value = serde_json::to_value(item).map_err(|_| "provider item is invalid")?;
150        super::snapshot_projection::redact_and_bound(&mut value, None);
151        serde_json::from_value(value).map_err(|_| "provider projection is invalid".into())
152    }
153}
154
155/// One execution and its exact source binding, captured on the same provider
156/// instance. The public provider snapshot remains its existing wire projection.
157pub struct BoundResult {
158    pub result: ProviderResult,
159    provider: String,
160    resource: String,
161    params: ProviderParams,
162    binding: Option<String>,
163}
164
165pub(crate) struct CurrentProviderChunks {
166    pub(crate) chunks: Vec<crate::core::content_chunk::ContentChunk>,
167    /// Original selected item and rendered projection, before policy masking.
168    pub(crate) original_bytes: usize,
169}
170
171pub(crate) fn digest(value: &impl Serialize) -> Option<String> {
172    let json = serde_json::to_value(value).ok()?;
173    Some(
174        blake3::hash(&crate::core::canonical::canonical_serialize(&json))
175            .to_hex()
176            .to_string(),
177    )
178}
179
180impl BoundResult {
181    pub fn execute(
182        provider: &dyn super::provider_trait::ContextProvider,
183        action: &str,
184        params: &ProviderParams,
185    ) -> Result<Self, String> {
186        let before = provider.reuse_binding(action, params);
187        let result = provider.execute(action, params)?;
188        if before.is_some() && (result.provider != provider.id() || result.resource_type != action)
189        {
190            return Err("provider result does not match its requested source".into());
191        }
192        if before != provider.reuse_binding(action, params) {
193            return Err("provider source changed during acquisition".into());
194        }
195        Ok(Self {
196            result,
197            provider: provider.id().into(),
198            resource: action.into(),
199            params: params.clone(),
200            binding: before,
201        })
202    }
203
204    pub fn chunks(
205        &self,
206        projection: &ProviderResult,
207    ) -> Vec<crate::core::content_chunk::ContentChunk> {
208        self.chunks_with_projection(projection, Projection::Raw)
209    }
210
211    pub fn chunks_with_projection(
212        &self,
213        projection: &ProviderResult,
214        kind: Projection,
215    ) -> Vec<crate::core::content_chunk::ContentChunk> {
216        let mut chunks = super::registry::result_to_chunks(projection);
217        for (chunk, item) in chunks.iter_mut().zip(&projection.items) {
218            let Some(binding) = &self.binding else {
219                continue;
220            };
221            let mut originals = self
222                .result
223                .items
224                .iter()
225                .filter(|original| original.id == item.id);
226            let Some(original) = originals.next() else {
227                continue;
228            };
229            if originals.next().is_some() {
230                continue;
231            }
232            // The stores key a chunk (and its origin) by URI. Two sources —
233            // another project, host or principal — number their items alike,
234            // so `gitlab://issues/1` of one evicted the other's copy. Scope
235            // the URI by a one-way tag of the binding; the item id stays the
236            // last segment, which fact keys are derived from.
237            let scope = &blake3::hash(binding.as_bytes()).to_hex()[..12];
238            chunk.file_path = format!("{}://{scope}/{}/{}", self.provider, self.resource, item.id);
239            chunk.origin = digest(original).map(|item_digest| ProviderOrigin {
240                version: 1,
241                provider: self.provider.clone(),
242                resource: self.resource.clone(),
243                binding: binding.clone(),
244                params: self.params.clone(),
245                item_id: item.id.clone(),
246                item_digest,
247                projection: kind,
248            });
249        }
250        chunks
251    }
252}
253
254impl ProviderOrigin {
255    /// Reacquire the resource and reconstruct the facts it can legitimately
256    /// support. Imported metadata cannot authorize an unrelated stored value.
257    pub(crate) fn current_facts(
258        &self,
259        project_root: &str,
260    ) -> Result<Vec<crate::core::knowledge_provider_extract::ExtractedFact>, String> {
261        let view = self.current_chunks(project_root)?;
262        Ok(crate::core::knowledge_provider_extract::extract_facts(
263            &view.chunks,
264        ))
265    }
266
267    /// Reuse the same current source authority for a receiver-owned origin.
268    /// Returned content is the policy-admitted projection, never a stored body.
269    pub(crate) fn current_chunks(
270        &self,
271        project_root: &str,
272    ) -> Result<CurrentProviderChunks, ReuseRefusal> {
273        use lean_ctx_protocol::context_lineage::SourceValidityV1 as V;
274        let refuse = |validity: V, reason: &str| ReuseRefusal::new(validity, reason);
275        if reuse_time_remaining().is_none_or(|remaining| remaining.is_zero()) {
276            return Err(refuse(
277                V::Unavailable,
278                "provider reuse deadline is unavailable or exhausted",
279            ));
280        }
281        reserve_reuse_check().map_err(|reason| ReuseRefusal::new(V::Unavailable, reason))?;
282        if self.version != 1
283            || self.binding.len() != 64
284            || self.item_digest.len() != 64
285            || self.provider.len() > 256
286            || self.resource.len() > 256
287            || self.item_id.len() > 4096
288            || serde_json::to_vec(&self.params).map_or(true, |v| v.len() > 16 * 1024)
289        {
290            return Err(refuse(V::Unsupported, "stored provider origin is invalid"));
291        }
292        if !crate::core::roles::active_role().is_tool_allowed("ctx_provider")
293            || crate::core::policy::runtime::active()
294                .is_some_and(|p| !p.tool_allowed("ctx_provider"))
295        {
296            return Err(refuse(
297                V::RevalidateRequired,
298                "provider access is not authorized",
299            ));
300        }
301        let root = std::path::Path::new(project_root);
302        let request = crate::core::policy::diagnostics::request_project().ok_or_else(|| {
303            refuse(
304                V::RevalidateRequired,
305                "provider reuse requires a project scope",
306            )
307        })?;
308        if crate::core::pathutil::safe_canonicalize_bounded(&request, 2000)
309            != crate::core::pathutil::safe_canonicalize_bounded(root, 2000)
310        {
311            return Err(refuse(
312                V::Revoked,
313                "provider reuse belongs to another project",
314            ));
315        }
316        if !crate::core::config::Config::load().providers.enabled {
317            return Err(refuse(V::RevalidateRequired, "providers are disabled"));
318        }
319        // A knowledge read must not register providers from stored project
320        // paths into process-global state. Preserve startup-pinned authority;
321        // otherwise construct just the currently configured source locally.
322        let provider: std::sync::Arc<dyn super::provider_trait::ContextProvider> =
323            if let Some(selected) = super::registry::global_registry().get_pinned(&self.provider) {
324                selected
325            } else if let Some(builtin) = super::registry::builtin_for_reuse(&self.provider) {
326                builtin
327            } else {
328                let config = super::config_provider::discovery::discover_configs(Some(root))
329                    .into_iter()
330                    .find(|entry| entry.config.id == self.provider)
331                    .ok_or_else(|| refuse(V::Unavailable, "stored provider is unavailable"))?;
332                std::sync::Arc::new(
333                    super::config_provider::ConfigProvider::from_config(config.config)
334                        .map_err(|reason| ReuseRefusal::new(V::Unavailable, reason))?,
335                )
336            };
337        if !provider.is_available()
338            || !provider
339                .supported_actions()
340                .contains(&self.resource.as_str())
341            || provider
342                .reuse_binding(&self.resource, &self.params)
343                .as_ref()
344                != Some(&self.binding)
345        {
346            return Err(refuse(
347                V::Revoked,
348                "stored provider binding is no longer authorized",
349            ));
350        }
351        let result = provider
352            .reacquire_item(&self.resource, &self.params, &self.item_id)
353            .map_err(ReuseRefusal::of_reacquisition)?;
354        if provider
355            .reuse_binding(&self.resource, &self.params)
356            .as_ref()
357            != Some(&self.binding)
358            || result.provider != self.provider
359            || result.resource_type != self.resource
360        {
361            return Err(refuse(
362                V::RevalidateRequired,
363                "provider source changed during reauthorization",
364            ));
365        }
366        let mut items = result.items.iter().filter(|item| item.id == self.item_id);
367        let item: &ProviderItem = items
368            .next()
369            .ok_or_else(|| refuse(V::Deleted, "stored resource is no longer accessible"))?;
370        if items.next().is_some() || digest(item).as_ref() != Some(&self.item_digest) {
371            return Err(refuse(
372                V::Stale,
373                "stored resource revision is no longer current",
374            ));
375        }
376        let projected = ProviderResult {
377            provider: self.provider.clone(),
378            resource_type: self.resource.clone(),
379            items: vec![
380                self.projection
381                    .item(item)
382                    .map_err(|reason| ReuseRefusal::new(V::Unsupported, reason))?,
383            ],
384            total_count: Some(1),
385            truncated: false,
386        };
387        let mut chunks = super::registry::result_to_chunks(&projected);
388        let rendered_bytes = chunks
389            .iter()
390            .try_fold(0usize, |sum, chunk| sum.checked_add(chunk.content.len()))
391            .ok_or_else(|| refuse(V::Unsupported, "provider source size overflow"))?;
392        let original_bytes = crate::core::canonical::canonical_serialize(item)
393            .len()
394            .max(rendered_bytes);
395        for chunk in &mut chunks {
396            chunk.content = crate::core::policy::content::protect_active(&chunk.content)
397                .map_err(|reason| ReuseRefusal::new(V::RevalidateRequired, reason))?
398                .into_owned();
399        }
400        Ok(CurrentProviderChunks {
401            chunks,
402            original_bytes,
403        })
404    }
405}