lean_ctx/core/context_os/
observation.rs1use crate::core::context_admission::stores::StoreAdmission;
5use crate::core::events::LeanCtxEvent;
6use crate::core::ocla_bus::OclaEvent;
7use std::fmt;
8use std::sync::{Mutex, MutexGuard, OnceLock, TryLockError};
9use std::time::{Duration, Instant};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum LocalObservationSource {
13 Ocla,
14 Legacy,
15}
16
17impl LocalObservationSource {
18 pub fn as_str(self) -> &'static str {
19 match self {
20 Self::Ocla => "ocla",
21 Self::Legacy => "legacy",
22 }
23 }
24
25 pub fn parse(value: &str) -> Result<Self, ObservationPersistenceError> {
26 match value {
27 "ocla" => Ok(Self::Ocla),
28 "legacy" => Ok(Self::Legacy),
29 other => Err(ObservationPersistenceError::InvalidRow(format!(
30 "unknown local observation source {other:?}"
31 ))),
32 }
33 }
34}
35
36#[derive(Debug)]
37pub enum ObservationPersistenceError {
38 Io(std::io::Error),
39 LockPoisoned,
40 LockTimeout,
41 IdentityConflict,
42 Serialization(String),
43 Sqlite(String),
44 InvalidRow(String),
45 Withheld,
47}
48
49#[derive(Debug, Clone, PartialEq)]
50pub enum LocalObservationPayload {
51 Ocla(OclaEvent),
52 Legacy(LeanCtxEvent),
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct LocalObservationV1 {
57 pub cursor_id: i64,
59 pub producer_instance: String,
61 pub source_id: u64,
63 pub source_timestamp: String,
65 pub source: LocalObservationSource,
66 pub payload: LocalObservationPayload,
67}
68
69impl fmt::Display for ObservationPersistenceError {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::Io(error) => write!(f, "context bus storage unavailable: {error}"),
73 Self::LockPoisoned => f.write_str("context bus observation lock poisoned"),
74 Self::LockTimeout => f.write_str("context bus observation lock timed out"),
75 Self::IdentityConflict => {
76 f.write_str("observation identity reused with different data")
77 }
78 Self::Serialization(error) => write!(f, "observation serialization failed: {error}"),
79 Self::Sqlite(error) => write!(f, "observation sqlite operation failed: {error}"),
80 Self::InvalidRow(error) => write!(f, "invalid local observation row: {error}"),
81 Self::Withheld => f.write_str("observation withheld by the context gateway"),
82 }
83 }
84}
85
86const SOURCE_TOOL: &str = "ctx_read";
96
97fn payload_text(payload: &serde_json::Value) -> String {
101 fn collect<'a>(value: &'a serde_json::Value, leaves: &mut Vec<&'a str>) {
102 match value {
103 serde_json::Value::String(text) => leaves.push(text),
104 serde_json::Value::Array(items) => {
105 for item in items {
106 collect(item, leaves);
107 }
108 }
109 serde_json::Value::Object(fields) => {
110 fields.values().for_each(|field| collect(field, leaves));
111 }
112 _ => {}
113 }
114 }
115 let mut leaves = Vec::new();
116 collect(payload, &mut leaves);
117 leaves.join("\n")
118}
119
120fn payload_path(payload: &serde_json::Value) -> Option<&str> {
122 let fields = payload.as_object()?;
123 fields
124 .get("path")
125 .and_then(serde_json::Value::as_str)
126 .or_else(|| fields.values().find_map(payload_path))
127}
128
129pub(crate) fn admit_payload(payload_json: &str) -> Result<(), ObservationPersistenceError> {
134 let text = payload_text(&serde_json::from_str(payload_json)?);
135 if kept_verbatim(&admission_snapshot(), &text) {
136 Ok(())
137 } else {
138 Err(ObservationPersistenceError::Withheld)
139 }
140}
141
142fn admission_snapshot() -> StoreAdmission {
148 StoreAdmission::with_policy(
149 crate::core::context_admission::AdmissionPolicy::from_config(
150 &crate::core::config::Config::load_arc(),
151 ),
152 )
153 .built_in_floor()
154}
155
156fn kept_verbatim(admission: &StoreAdmission, text: &str) -> bool {
164 text.is_empty() || admission.admit_shell_command(text).as_deref() == Some(text)
165}
166
167pub(crate) struct ObservationGate {
190 files: Option<crate::core::bm25_index::source_gate::SourceGate>,
191 admission: StoreAdmission,
192 protected: bool,
194 withheld: usize,
195}
196
197impl ObservationGate {
198 pub(crate) fn for_project(project: Option<&std::path::Path>) -> Self {
201 use crate::core::bm25_index::source_gate::SourceGate;
202 let request = crate::core::admitted_sources::request_root();
203 let admission = admission_snapshot();
206 let root = match project {
207 Some(root) => Some(root),
208 None if request.is_empty() => None,
209 None => Some(std::path::Path::new(&request)),
210 };
211 let files = root.map(|root| SourceGate::with_admission(root, SOURCE_TOOL, &admission));
212 Self {
213 files,
214 admission,
215 protected: crate::core::context_admission::stored::source_authority_required(),
216 withheld: 0,
217 }
218 }
219
220 pub(crate) fn serves<T: serde::Serialize>(&mut self, observation: &T) -> bool {
222 let served = serde_json::to_value(observation).is_ok_and(|payload| {
223 if !kept_verbatim(&self.admission, &payload_text(&payload)) {
226 return false;
227 }
228 match (payload_path(&payload), self.files.as_mut()) {
229 (Some(path), Some(files)) => files.serves(path, ""),
230 _ => !self.protected,
234 }
235 });
236 if !served {
237 self.withheld += 1;
238 }
239 served
240 }
241
242 pub(crate) fn gateway_unchanged(&self) -> bool {
248 self.admission.digest() == admission_snapshot().digest()
249 }
250
251 pub(crate) fn withheld(&self) -> usize {
253 self.withheld
254 }
255}
256
257impl std::error::Error for ObservationPersistenceError {}
258
259impl From<serde_json::Error> for ObservationPersistenceError {
260 fn from(error: serde_json::Error) -> Self {
261 Self::Serialization(error.to_string())
262 }
263}
264
265impl From<rusqlite::Error> for ObservationPersistenceError {
266 fn from(error: rusqlite::Error) -> Self {
267 Self::Sqlite(error.to_string())
268 }
269}
270
271impl From<std::io::Error> for ObservationPersistenceError {
272 fn from(error: std::io::Error) -> Self {
273 Self::Io(error)
274 }
275}
276
277pub(crate) fn validate_identity(
278 producer_instance: &str,
279 source_id: u64,
280) -> Result<(), ObservationPersistenceError> {
281 if source_id == 0
282 || !uuid::Uuid::parse_str(producer_instance)
283 .is_ok_and(|id| id.to_string() == producer_instance && !id.is_nil())
284 {
285 return Err(ObservationPersistenceError::InvalidRow(
286 "invalid producer instance or source ID".into(),
287 ));
288 }
289 Ok(())
290}
291
292pub(crate) fn decode_row(
298 row: &rusqlite::Row<'_>,
299) -> Result<Option<LocalObservationV1>, ObservationPersistenceError> {
300 if row.get::<_, i64>(6)? != 1 {
301 return Err(ObservationPersistenceError::InvalidRow(
302 "unsupported local observation schema".into(),
303 ));
304 }
305 let cursor_id: i64 = row.get(0)?;
306 let source = LocalObservationSource::parse(row.get::<_, String>(1)?.as_str())?;
307 let source_id_raw: String = row.get(2)?;
308 let source_id = source_id_raw.parse::<u64>().map_err(|_| {
309 ObservationPersistenceError::InvalidRow("invalid source ID encoding".into())
310 })?;
311 let producer_instance: String = row.get(5)?;
312 validate_identity(&producer_instance, source_id)?;
313 if cursor_id <= 0 || source_id.to_string() != source_id_raw {
314 return Err(ObservationPersistenceError::InvalidRow(
315 "noncanonical observation identity".into(),
316 ));
317 }
318 let source_timestamp: String = row.get(3)?;
319 let payload_json: String = row.get(4)?;
320 let payload = match source {
321 LocalObservationSource::Ocla => {
322 if !source_timestamp
323 .parse::<u64>()
324 .is_ok_and(|value| value.to_string() == source_timestamp)
325 {
326 return Err(ObservationPersistenceError::InvalidRow(
327 "invalid OCLA timestamp encoding".into(),
328 ));
329 }
330 LocalObservationPayload::Ocla(serde_json::from_str(&payload_json)?)
331 }
332 LocalObservationSource::Legacy => {
333 if crate::core::events::is_retired_event_record(&payload_json) {
334 return Ok(None);
335 }
336 let event: LeanCtxEvent = serde_json::from_str(&payload_json)?;
337 if event.id != source_id || event.timestamp != source_timestamp {
338 return Err(ObservationPersistenceError::InvalidRow(
339 "legacy payload contradicts observation identity or timestamp".into(),
340 ));
341 }
342 LocalObservationPayload::Legacy(event)
343 }
344 };
345 Ok(Some(LocalObservationV1 {
346 cursor_id,
347 producer_instance,
348 source_id,
349 source_timestamp,
350 source,
351 payload,
352 }))
353}
354
355pub(crate) fn producer_instance() -> &'static str {
356 static INSTANCE: OnceLock<String> = OnceLock::new();
357 INSTANCE.get_or_init(|| uuid::Uuid::new_v4().to_string())
358}
359
360pub(crate) fn lock_observation<T>(
362 mutex: &Mutex<T>,
363) -> Result<MutexGuard<'_, T>, ObservationPersistenceError> {
364 let started = Instant::now();
365 loop {
366 match mutex.try_lock() {
367 Ok(guard) => return Ok(guard),
368 Err(TryLockError::Poisoned(_)) => {
369 return Err(ObservationPersistenceError::LockPoisoned);
370 }
371 Err(TryLockError::WouldBlock) => {
372 if started.elapsed() >= Duration::from_secs(5) {
373 return Err(ObservationPersistenceError::LockTimeout);
374 }
375 std::thread::sleep(Duration::from_millis(1));
376 }
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::core::context_os::context_bus::ContextBus;
385 use crate::core::events::EventKind;
386
387 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
388
389 fn event(id: u64, kind: EventKind) -> LeanCtxEvent {
390 LeanCtxEvent {
391 id,
392 timestamp: "2026-01-01T00:00:00.000".to_owned(),
393 kind,
394 }
395 }
396
397 #[test]
402 fn an_observation_is_shown_only_while_the_file_it_names_is_admitted() {
403 let _data = crate::core::data_dir::isolated_data_dir();
404 let community = crate::core::policy::runtime::TestPolicyOverride::set(None);
405 let project = tempfile::tempdir().expect("project");
406 let root = project.path().canonicalize().expect("canonical root");
407 let store = tempfile::tempdir().expect("store");
408 let bus = ContextBus::open_at(store.path().join("context-bus.db"));
409 std::fs::write(root.join("billing.rs"), "pub fn settle() {}\n").expect("source");
410 let hit = event(
411 1,
412 EventKind::CacheHit {
413 path: "billing.rs".to_owned(),
414 saved_tokens: 10,
415 },
416 );
417 bus.append_legacy_observation(producer_instance(), &hit)
418 .expect("an admitted payload is stored");
419 let shown = || -> usize {
420 let mut gate = ObservationGate::for_project(Some(root.as_path()));
421 bus.read_legacy_observations(10)
422 .expect("rows")
423 .iter()
424 .filter(|stored| gate.serves(*stored))
425 .count()
426 };
427 assert_eq!(shown(), 1);
428
429 std::fs::remove_file(root.join("billing.rs")).expect("withdraw");
430 assert_eq!(shown(), 0);
431 let mut gate = ObservationGate::for_project(Some(root.as_path()));
432 assert!(!gate.serves(&hit));
433 assert_eq!(gate.withheld(), 1);
434
435 let keyed = event(
436 2,
437 EventKind::VerificationWarning {
438 warning_kind: "drift".to_owned(),
439 detail: format!("deploy token {AWS_KEY}"),
440 severity: "warn".to_owned(),
441 },
442 );
443 let refused = bus
444 .append_legacy_observation(producer_instance(), &keyed)
445 .expect_err("a payload admission would mask is not persisted");
446 assert!(matches!(refused, ObservationPersistenceError::Withheld));
447 assert!(!refused.to_string().contains(AWS_KEY));
448 assert_eq!(bus.read_legacy_observations(10).expect("rows").len(), 1);
449
450 std::fs::write(root.join("ledger.rs"), "pub fn post() {}\n").expect("source");
454 let call = |id: u64, mode: &str| {
455 event(
456 id,
457 EventKind::ToolCall {
458 tool: "ctx_read".to_owned(),
459 tokens_original: 10,
460 tokens_saved: 5,
461 mode: Some(mode.to_owned()),
462 duration_ms: 1,
463 path: Some("ledger.rs".to_owned()),
464 },
465 )
466 };
467 for stored in [call(3, "map"), call(4, "CONFIDENTIAL")] {
468 bus.append_legacy_observation(producer_instance(), &stored)
469 .expect("stored under the looser rule");
470 }
471 drop(community);
472 let policy = crate::core::policy::load(
473 "name = \"observation-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
474 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
475 )
476 .expect("policy");
477 let _tightened = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
478 let mut gate = ObservationGate::for_project(Some(root.as_path()));
479 let shown: Vec<u64> = bus
480 .read_legacy_observations(10)
481 .expect("rows")
482 .iter()
483 .filter(|stored| gate.serves(*stored))
484 .map(|stored| stored.id)
485 .collect();
486 assert_eq!(shown, [3]);
487 }
488}