Skip to main content

hatchery_observation_engine/
lib.rs

1//! Bounded in-memory reducer for read-only session observations.
2
3pub mod node_projection;
4
5use hatchery_observation_api::{
6    ManagedRecordLink, ManagedSessionKey, NodeCursor, NodeId, NodeIncarnationId,
7    ObservationApiError, ObservationGap, ObservationIngressEnvelope, ObservationIngressPayload,
8    ObservationRecordInventory, ObservationResyncBatch, ObservationTarget,
9    ProjectionAvailability, ProjectionFreshness,
10    RuntimeSessionKey, CURSOR_JOURNAL_MAX, FILES_PER_SESSION_MAX, INGRESS_BATCH_MAX,
11    INTERACTIONS_PER_SESSION_MAX, NODE_ROUTES_MAX, OWNED_PROCESSES_PER_SESSION_MAX,
12    PROJECTIONS_MAX, RETIRED_INCARNATIONS_MAX, SUBAGENTS_PER_SESSION_MAX,
13    TIMELINE_PER_SESSION_MAX, TOOLS_PER_SESSION_MAX,
14};
15use hatchery_observation_protocol::{
16    ObservationCapabilitiesV1, ObservationEvidenceV1, ObservationInteractionOutcomeV1,
17    ObservationKindV1, ObservationSourceFamilyV1, ObservationTodoItemV1,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, HashSet, VecDeque};
21use thiserror::Error;
22
23#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
24pub enum ApplyOutcome {
25    Applied,
26    Duplicate,
27}
28
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub enum CorrelationState {
31    Pending,
32    Completed { success: Option<bool> },
33    Resolved { outcome: ObservationInteractionOutcomeV1 },
34    UnknownAfterGap,
35    OrphanCompletion { success: Option<bool> },
36    OrphanResolution { outcome: ObservationInteractionOutcomeV1 },
37}
38
39impl CorrelationState {
40    fn is_active(&self) -> bool {
41        matches!(self, Self::Pending)
42    }
43}
44
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
46pub struct CorrelationProjection {
47    pub correlation_id: String,
48    pub class: Option<String>,
49    pub evidence: ObservationEvidenceV1,
50    pub state: CorrelationState,
51}
52
53#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
54pub struct FileProjection {
55    pub path: Option<String>,
56    pub evidence: ObservationEvidenceV1,
57}
58
59#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
60pub struct UsageTotals {
61    pub input_tokens: u64,
62    pub output_tokens: u64,
63    pub cache_read_tokens: u64,
64    pub cache_write_tokens: u64,
65    pub reasoning_tokens: u64,
66}
67
68impl UsageTotals {
69    fn checked_delta(self, previous: Self) -> Option<Self> {
70        Some(Self {
71            input_tokens: self.input_tokens.checked_sub(previous.input_tokens)?,
72            output_tokens: self.output_tokens.checked_sub(previous.output_tokens)?,
73            cache_read_tokens: self.cache_read_tokens.checked_sub(previous.cache_read_tokens)?,
74            cache_write_tokens: self.cache_write_tokens.checked_sub(previous.cache_write_tokens)?,
75            reasoning_tokens: self.reasoning_tokens.checked_sub(previous.reasoning_tokens)?,
76        })
77    }
78
79    fn saturating_add(self, other: Self) -> Self {
80        Self {
81            input_tokens: self.input_tokens.saturating_add(other.input_tokens),
82            output_tokens: self.output_tokens.saturating_add(other.output_tokens),
83            cache_read_tokens: self.cache_read_tokens.saturating_add(other.cache_read_tokens),
84            cache_write_tokens: self.cache_write_tokens.saturating_add(other.cache_write_tokens),
85            reasoning_tokens: self.reasoning_tokens.saturating_add(other.reasoning_tokens),
86        }
87    }
88}
89
90#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
91#[serde(rename_all = "kebab-case")]
92pub enum ContextOccupancyProvenance {
93    CumulativeUsage,
94    PerTurnUsageSynthesis,
95    ExactCurrentWindow,
96}
97
98#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
99pub struct ContextOccupancySnapshot {
100    /// `None` when the source reported `used_tokens`/`context_window`
101    /// alone and never observed how the total decomposes -- same
102    /// not-observed-not-zero convention `reasoning_tokens` already
103    /// follows here, now shared by every segment `ProviderEvent::
104    /// UsageUpdated` cannot supply.
105    pub uncached_input_tokens: Option<u64>,
106    pub output_tokens: Option<u64>,
107    pub cache_read_tokens: Option<u64>,
108    pub cache_write_tokens: Option<u64>,
109    pub reasoning_tokens: Option<u64>,
110    pub unattributed_tokens: Option<u64>,
111    pub used_tokens: u64,
112    pub context_window: Option<u64>,
113    pub evidence: ObservationEvidenceV1,
114    pub provenance: ContextOccupancyProvenance,
115}
116
117impl ContextOccupancySnapshot {
118    pub fn occupied_tokens(self) -> u64 {
119        self.used_tokens
120    }
121}
122
123#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
124pub struct UsageProjection {
125    pub observed_delta: UsageTotals,
126    pub last_cumulative: Option<UsageTotals>,
127    pub cumulative_evidence: Option<ObservationEvidenceV1>,
128    pub context_window: Option<u64>,
129    #[serde(default)]
130    pub context_occupancy: Option<ContextOccupancySnapshot>,
131}
132
133#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
134pub struct TodoRevisionProjection {
135    pub revision: u64,
136    pub items: Vec<ObservationTodoItemV1>,
137    pub complete: bool,
138    pub evidence: ObservationEvidenceV1,
139}
140
141#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
142pub struct TodoProjection {
143    pub current: Option<TodoRevisionProjection>,
144    pub previous: Option<TodoRevisionProjection>,
145    pub conflict: bool,
146}
147
148#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
149pub struct TimelineEntry {
150    pub cursor: NodeCursor,
151    pub received_at_ms: u64,
152    pub evidence: ObservationEvidenceV1,
153    pub kind: ObservationKindV1,
154}
155
156#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
157pub struct SourceCapabilitiesProjection {
158    pub evidence: ObservationEvidenceV1,
159    pub source_family: ObservationSourceFamilyV1,
160    pub source_adapter: String,
161    pub capabilities: ObservationCapabilitiesV1,
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165pub struct HistoryProjection {
166    pub evidence: ObservationEvidenceV1,
167    pub message_count: u64,
168    pub message_count_exact: bool,
169    pub completed_turn_count: Option<u64>,
170    pub total_tokens: Option<u64>,
171}
172
173#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
174pub struct SessionProjection {
175    pub target: ObservationTarget,
176    pub availability: ProjectionAvailability,
177    pub freshness: ProjectionFreshness,
178    pub transport_incomplete: bool,
179    pub incomplete_evidence: Vec<ObservationEvidenceV1>,
180    pub stale_evidence: Vec<ObservationEvidenceV1>,
181    pub source_capabilities: Vec<SourceCapabilitiesProjection>,
182    pub history: Option<HistoryProjection>,
183    pub todos: TodoProjection,
184    pub tools: Vec<CorrelationProjection>,
185    pub subagents: Vec<CorrelationProjection>,
186    pub interactions: Vec<CorrelationProjection>,
187    pub owned_processes: Vec<CorrelationProjection>,
188    pub files: VecDeque<FileProjection>,
189    pub usage: UsageProjection,
190    pub timeline: VecDeque<TimelineEntry>,
191    /// Running tally of every `ObservationKindV1::ActionBlocked` this
192    /// session has ever received. Unlike `timeline` (a
193    /// `TIMELINE_PER_SESSION_MAX`-bounded ring buffer), this never shrinks,
194    /// so a long-lived session's total survives past whatever churned out
195    /// of the ring. `#[serde(default)]` reads a checkpoint written before
196    /// this field existed as zero instead of failing to deserialize it.
197    #[serde(default)]
198    pub blocked_count: u64,
199    /// `received_at_ms` of the most recent `ActionBlocked` observation, or
200    /// `None` if this session has never been blocked. Same
201    /// backward-compatibility rule as `blocked_count`.
202    #[serde(default)]
203    pub last_blocked_at_ms: Option<u64>,
204}
205
206impl SessionProjection {
207    fn new(target: ObservationTarget) -> Self {
208        Self {
209            target,
210            availability: ProjectionAvailability::Unknown,
211            freshness: ProjectionFreshness::Unavailable,
212            transport_incomplete: false,
213            incomplete_evidence: Vec::new(),
214            stale_evidence: Vec::new(),
215            source_capabilities: Vec::new(),
216            history: None,
217            todos: TodoProjection::default(),
218            tools: Vec::new(),
219            subagents: Vec::new(),
220            interactions: Vec::new(),
221            owned_processes: Vec::new(),
222            files: VecDeque::new(),
223            usage: UsageProjection::default(),
224            timeline: VecDeque::new(),
225            blocked_count: 0,
226            last_blocked_at_ms: None,
227        }
228    }
229
230    fn mark_observed(&mut self) {
231        if matches!(
232            self.availability,
233            ProjectionAvailability::Unknown | ProjectionAvailability::NotObserved
234        ) || (self.availability == ProjectionAvailability::Frozen
235            && self.freshness == ProjectionFreshness::Unavailable)
236        {
237            self.availability = ProjectionAvailability::Current;
238        }
239        if self.availability != ProjectionAvailability::Frozen
240            && !self.transport_incomplete
241            && self.incomplete_evidence.is_empty()
242            && self.stale_evidence.is_empty()
243        {
244            self.freshness = ProjectionFreshness::Live;
245        }
246    }
247
248    fn mark_inventory_present(&mut self, linked: bool) {
249        if self.availability == ProjectionAvailability::Frozen
250            && self.freshness == ProjectionFreshness::ReplacedIncarnation
251        {
252            return;
253        }
254        if self.transport_incomplete || !self.incomplete_evidence.is_empty() {
255            self.availability = ProjectionAvailability::Partial;
256            self.freshness = ProjectionFreshness::IncompleteAfterGap;
257            return;
258        }
259
260        let observed = !self.timeline.is_empty() || !self.source_capabilities.is_empty();
261        if !observed {
262            self.availability = ProjectionAvailability::NotObserved;
263            self.freshness = ProjectionFreshness::LastKnown;
264        } else if !self.stale_evidence.is_empty() {
265            self.availability = ProjectionAvailability::Current;
266            self.freshness = ProjectionFreshness::Stale;
267        } else {
268            self.availability = ProjectionAvailability::Current;
269            if !linked
270                || matches!(
271                    self.freshness,
272                    ProjectionFreshness::LastKnown
273                        | ProjectionFreshness::ReplacedIncarnation
274                        | ProjectionFreshness::Unavailable
275                )
276            {
277                self.freshness = ProjectionFreshness::LastKnown;
278            }
279        }
280    }
281
282    fn mark_inventory_absent(&mut self) {
283        self.availability = ProjectionAvailability::Frozen;
284        self.freshness = ProjectionFreshness::Unavailable;
285    }
286
287    fn refresh_integrity(&mut self) {
288        if self.availability == ProjectionAvailability::Frozen {
289            return;
290        }
291        if self.transport_incomplete || !self.incomplete_evidence.is_empty() {
292            self.availability = ProjectionAvailability::Partial;
293            self.freshness = ProjectionFreshness::IncompleteAfterGap;
294        } else if !self.stale_evidence.is_empty() {
295            if self.availability == ProjectionAvailability::Partial {
296                self.availability = ProjectionAvailability::Current;
297            }
298            self.freshness = ProjectionFreshness::Stale;
299        } else {
300            if self.availability == ProjectionAvailability::Partial {
301                self.availability = ProjectionAvailability::Current;
302            }
303            if !matches!(
304                self.availability,
305                ProjectionAvailability::Unknown | ProjectionAvailability::NotObserved
306            ) {
307                self.freshness = ProjectionFreshness::Live;
308            }
309        }
310    }
311
312    fn mark_transport_gap(&mut self) {
313        self.transport_incomplete = true;
314        self.availability = ProjectionAvailability::Partial;
315        self.freshness = ProjectionFreshness::IncompleteAfterGap;
316        self.mark_pending_unknown();
317    }
318
319    fn mark_source_incomplete(&mut self, evidence: ObservationEvidenceV1, unknown_pending: bool) {
320        push_unique_evidence(&mut self.incomplete_evidence, evidence);
321        self.availability = ProjectionAvailability::Partial;
322        self.freshness = ProjectionFreshness::IncompleteAfterGap;
323        if unknown_pending {
324            self.mark_pending_unknown();
325        }
326    }
327
328    fn mark_source_stale(&mut self, evidence: ObservationEvidenceV1) {
329        push_unique_evidence(&mut self.stale_evidence, evidence);
330        self.refresh_integrity();
331    }
332
333    fn mark_pending_unknown(&mut self) {
334        mark_pending_unknown(&mut self.tools);
335        mark_pending_unknown(&mut self.subagents);
336        mark_pending_unknown(&mut self.interactions);
337        mark_pending_unknown(&mut self.owned_processes);
338    }
339
340    fn source_reset(&mut self, evidence: ObservationEvidenceV1) {
341        self.source_capabilities.retain(|entry| entry.evidence != evidence);
342        if self.history.as_ref().map(|history| history.evidence) == Some(evidence) {
343            self.history = None;
344        }
345        self.tools.retain(|entry| entry.evidence != evidence);
346        self.subagents.retain(|entry| entry.evidence != evidence);
347        self.interactions.retain(|entry| entry.evidence != evidence);
348        self.owned_processes.retain(|entry| entry.evidence != evidence);
349        self.files.retain(|entry| entry.evidence != evidence);
350        if self.todos.current.as_ref().map(|todo| todo.evidence) == Some(evidence) {
351            self.todos.current = None;
352            self.todos.previous = None;
353            self.todos.conflict = false;
354        }
355        if self.usage.cumulative_evidence == Some(evidence) {
356            self.usage.last_cumulative = None;
357            self.usage.cumulative_evidence = None;
358        }
359        if self.usage.context_occupancy.map(|snapshot| snapshot.evidence) == Some(evidence) {
360            self.usage.context_occupancy = None;
361        }
362        self.incomplete_evidence.retain(|current| *current != evidence);
363        self.stale_evidence.retain(|current| *current != evidence);
364        self.refresh_integrity();
365    }
366
367    fn push_timeline(&mut self, entry: TimelineEntry) {
368        if self.timeline.len() == TIMELINE_PER_SESSION_MAX {
369            self.timeline.pop_front();
370        }
371        self.timeline.push_back(entry);
372    }
373}
374
375fn push_unique_evidence(
376    entries: &mut Vec<ObservationEvidenceV1>,
377    evidence: ObservationEvidenceV1,
378) {
379    if !entries.contains(&evidence) {
380        entries.push(evidence);
381    }
382}
383
384fn mark_pending_unknown(entries: &mut [CorrelationProjection]) {
385    for entry in entries {
386        if entry.state == CorrelationState::Pending {
387            entry.state = CorrelationState::UnknownAfterGap;
388        }
389    }
390}
391
392#[derive(Clone, Debug, Eq, Hash, PartialEq)]
393struct CursorKey {
394    node_id: NodeId,
395    incarnation_id: NodeIncarnationId,
396    sequence: u64,
397}
398
399pub const OBSERVATION_ENGINE_CHECKPOINT_VERSION_V1: u16 = 1;
400pub const OBSERVATION_ROUTE_HISTORY_MAX: usize =
401    RETIRED_INCARNATIONS_MAX + NODE_ROUTES_MAX;
402// Keep enough exact canonical ingress to deduplicate the immediate alternate
403// transport replay while leaving ample headroom before the hard journal bound.
404const CURSOR_JOURNAL_CHECKPOINT_RETAINED_MAX: usize = 256;
405
406#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
407#[serde(deny_unknown_fields)]
408pub struct ObservationCheckpointCursorV1 {
409    pub node_id: NodeId,
410    pub cursor: NodeCursor,
411    pub payload: ObservationIngressPayload,
412}
413
414#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
415#[serde(deny_unknown_fields)]
416pub struct ObservationCheckpointRouteV1 {
417    pub node_id: NodeId,
418    pub incarnation_id: NodeIncarnationId,
419}
420
421#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
422#[serde(deny_unknown_fields)]
423pub struct ObservationCheckpointRouteCursorV1 {
424    pub node_id: NodeId,
425    pub cursor: NodeCursor,
426}
427
428#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
429#[serde(deny_unknown_fields)]
430pub struct ObservationEngineCheckpointV1 {
431    pub version: u16,
432    pub projections: Vec<SessionProjection>,
433    pub cursor_journal: Vec<ObservationCheckpointCursorV1>,
434    pub current_incarnations: Vec<ObservationCheckpointRouteV1>,
435    pub retired_incarnations: Vec<ObservationCheckpointRouteV1>,
436    pub managed_links: Vec<ManagedRecordLink>,
437    pub transport_gaps: Vec<ObservationCheckpointRouteV1>,
438    pub retention_floors: Vec<ObservationCheckpointRouteCursorV1>,
439    pub high_watermarks: Vec<ObservationCheckpointRouteCursorV1>,
440}
441
442#[derive(Clone, Debug, Default, Eq, PartialEq)]
443pub struct ObservationEngine {
444    projections: HashMap<ObservationTarget, SessionProjection>,
445    journal: HashMap<CursorKey, ObservationIngressEnvelope>,
446    current_incarnations: HashMap<NodeId, NodeIncarnationId>,
447    retired_incarnations: HashSet<(NodeId, NodeIncarnationId)>,
448    managed_links: HashMap<ManagedSessionKey, Option<RuntimeSessionKey>>,
449    transport_gaps: HashSet<(NodeId, NodeIncarnationId)>,
450    retention_floors: HashMap<(NodeId, NodeIncarnationId), u64>,
451    high_watermarks: HashMap<(NodeId, NodeIncarnationId), u64>,
452}
453
454impl ObservationEngine {
455    pub fn new() -> Self {
456        Self::default()
457    }
458
459    pub fn projection(&self, target: &ObservationTarget) -> Option<&SessionProjection> {
460        self.projections.get(target)
461    }
462
463    pub fn projection_count(&self) -> usize {
464        self.projections.len()
465    }
466
467    pub fn projections(&self) -> impl Iterator<Item = (&ObservationTarget, &SessionProjection)> {
468        self.projections.iter()
469    }
470
471    pub fn managed_runtime(&self, key: &ManagedSessionKey) -> Option<&RuntimeSessionKey> {
472        self.managed_links.get(key).and_then(Option::as_ref)
473    }
474
475    pub fn durable_resume_cursors(&self) -> Vec<(NodeId, NodeCursor)> {
476        let mut cursors = self
477            .high_watermarks
478            .iter()
479            .map(|((node_id, incarnation_id), sequence)| {
480                (node_id.clone(), NodeCursor {
481                    incarnation_id: *incarnation_id,
482                    sequence: *sequence,
483                })
484            })
485            .collect::<Vec<_>>();
486        cursors.sort_by(|left, right| {
487            left.0.cmp(&right.0)
488                .then_with(|| left.1.incarnation_id.cmp(&right.1.incarnation_id))
489        });
490        cursors
491    }
492
493    pub fn checkpoint(&self) -> ObservationEngineCheckpointV1 {
494        self.to_checkpoint()
495    }
496
497    pub fn restore(
498        checkpoint: ObservationEngineCheckpointV1,
499    ) -> Result<Self, ObservationEngineError> {
500        checkpoint.validate()?;
501        let mut engine = Self::new();
502        for projection in checkpoint.projections {
503            engine.projections.insert(projection.target.clone(), projection);
504        }
505        for entry in checkpoint.cursor_journal {
506            let key = CursorKey {
507                node_id: entry.node_id.clone(),
508                incarnation_id: entry.cursor.incarnation_id,
509                sequence: entry.cursor.sequence,
510            };
511            engine.journal.insert(key, ObservationIngressEnvelope {
512                node_id: entry.node_id,
513                cursor: entry.cursor,
514                received_at_ms: 1,
515                transport: hatchery_observation_api::ObservationTransport::DirectNode,
516                payload: entry.payload,
517            });
518        }
519        for route in checkpoint.current_incarnations {
520            engine.current_incarnations.insert(route.node_id, route.incarnation_id);
521        }
522        for route in checkpoint.retired_incarnations {
523            engine.retired_incarnations.insert((route.node_id, route.incarnation_id));
524        }
525        for link in checkpoint.managed_links {
526            engine.managed_links.insert(link.managed, link.runtime);
527        }
528        for route in checkpoint.transport_gaps {
529            engine.transport_gaps.insert((route.node_id, route.incarnation_id));
530        }
531        for route in checkpoint.retention_floors {
532            engine.retention_floors.insert(
533                (route.node_id, route.cursor.incarnation_id),
534                route.cursor.sequence,
535            );
536        }
537        for route in checkpoint.high_watermarks {
538            engine.high_watermarks.insert(
539                (route.node_id, route.cursor.incarnation_id),
540                route.cursor.sequence,
541            );
542        }
543        engine.retire_superseded_projections("checkpoint load");
544        Ok(engine)
545    }
546
547    pub fn prepare(
548        &self,
549        envelope: ObservationIngressEnvelope,
550    ) -> Result<PreparedBatch, ObservationEngineError> {
551        self.prepare_batch(&[envelope])
552    }
553
554    pub fn prepare_batch(
555        &self,
556        envelopes: &[ObservationIngressEnvelope],
557    ) -> Result<PreparedBatch, ObservationEngineError> {
558        if envelopes.len() > INGRESS_BATCH_MAX {
559            return Err(ObservationEngineError::CapacityExhausted {
560                collection: "ingress batch",
561                max: INGRESS_BATCH_MAX,
562            });
563        }
564        let mut next = self.clone();
565        let mut outcomes = Vec::with_capacity(envelopes.len());
566        for envelope in envelopes {
567            outcomes.push(next.apply_envelope(envelope.clone())?);
568        }
569        Ok(PreparedBatch { next, outcomes })
570    }
571
572    pub fn prepare_resync(
573        &self,
574        batch: &ObservationResyncBatch,
575    ) -> Result<PreparedBatch, ObservationEngineError> {
576        batch.validate()?;
577        let mut next = self.clone();
578        next.observe_incarnation(&batch.node_id, batch.incarnation_id)?;
579        next.record_high_watermark(
580            &batch.node_id,
581            batch.incarnation_id,
582            batch.high_watermark.sequence,
583        );
584        next.apply_resync_records(batch)?;
585        let proves_no_eviction = batch.requested_after.saturating_add(1)
586            >= batch.oldest_available_sequence;
587        if proves_no_eviction {
588            next.clear_node_transport_gap(&batch.node_id, batch.incarnation_id);
589        } else {
590            next.mark_node_gap(&batch.node_id, batch.incarnation_id);
591        }
592        let mut outcomes = Vec::with_capacity(batch.events.len());
593        for envelope in &batch.events {
594            outcomes.push(next.apply_envelope(envelope.clone())?);
595        }
596        Ok(PreparedBatch { next, outcomes })
597    }
598
599    /// Atomically applies a bounded inventory of managed records for one exact
600    /// Node incarnation.
601    ///
602    /// Inventory is record/link state, not transport recovery evidence. This
603    /// operation deliberately never clears or creates an observation transport
604    /// gap.
605    pub fn prepare_record_inventory(
606        &self,
607        node_id: &NodeId,
608        incarnation_id: NodeIncarnationId,
609        records: &[ManagedRecordLink],
610        complete: bool,
611    ) -> Result<PreparedBatch, ObservationEngineError> {
612        let inventory = ObservationRecordInventory {
613            node_id: node_id.clone(),
614            incarnation_id,
615            records: records.to_vec(),
616            complete,
617        };
618        inventory.validate()?;
619
620        let mut next = self.clone();
621        next.observe_incarnation(node_id, incarnation_id)?;
622        next.apply_record_inventory(node_id, incarnation_id, records, complete)?;
623        Ok(PreparedBatch {
624            next,
625            outcomes: Vec::new(),
626        })
627    }
628
629    /// Commits an already validated and fully reduced state without failure.
630    pub fn accept(&mut self, prepared: PreparedBatch) {
631        *self = prepared.next;
632    }
633
634    fn apply_envelope(
635        &mut self,
636        envelope: ObservationIngressEnvelope,
637    ) -> Result<ApplyOutcome, ObservationEngineError> {
638        envelope.validate()?;
639        let cursor_key = CursorKey {
640            node_id: envelope.node_id.clone(),
641            incarnation_id: envelope.cursor.incarnation_id,
642            sequence: envelope.cursor.sequence,
643        };
644        if let Some(existing) = self.journal.get(&cursor_key) {
645            if existing.canonical_eq(&envelope) {
646                return Ok(ApplyOutcome::Duplicate);
647            }
648            return Err(ObservationEngineError::CursorCollision {
649                node_id: envelope.node_id,
650                cursor: envelope.cursor,
651            });
652        }
653        if self
654            .retention_floors
655            .get(&(envelope.node_id.clone(), envelope.cursor.incarnation_id))
656            .is_some_and(|floor| envelope.cursor.sequence <= *floor)
657        {
658            return Err(ObservationEngineError::BelowRetentionFloor {
659                node_id: envelope.node_id,
660                cursor: envelope.cursor,
661            });
662        }
663        if self.journal.len() >= CURSOR_JOURNAL_MAX {
664            self.compact_cursor_journal();
665        }
666
667        self.observe_incarnation(&envelope.node_id, envelope.cursor.incarnation_id)?;
668        self.apply_payload(&envelope)?;
669        self.record_high_watermark(
670            &envelope.node_id,
671            envelope.cursor.incarnation_id,
672            envelope.cursor.sequence,
673        );
674        self.journal.insert(cursor_key, envelope);
675        Ok(ApplyOutcome::Applied)
676    }
677
678    fn observe_incarnation(
679        &mut self,
680        node_id: &NodeId,
681        incarnation_id: NodeIncarnationId,
682    ) -> Result<(), ObservationEngineError> {
683        match self.current_incarnations.get(node_id).copied() {
684            None => {
685                if self.current_incarnations.len() == NODE_ROUTES_MAX {
686                    return Err(ObservationEngineError::CapacityExhausted {
687                        collection: "node routes",
688                        max: NODE_ROUTES_MAX,
689                    });
690                }
691                self.current_incarnations.insert(node_id.clone(), incarnation_id);
692            }
693            Some(current) if current == incarnation_id => {}
694            Some(_) if self.retired_incarnations.contains(&(node_id.clone(), incarnation_id)) => {
695                return Err(ObservationEngineError::ReplacedIncarnation {
696                    node_id: node_id.clone(),
697                    incarnation_id,
698                });
699            }
700            Some(current) => {
701                if self.retired_incarnations.len() == RETIRED_INCARNATIONS_MAX {
702                    return Err(ObservationEngineError::CapacityExhausted {
703                        collection: "retired incarnations",
704                        max: RETIRED_INCARNATIONS_MAX,
705                    });
706                }
707                self.retired_incarnations.insert((node_id.clone(), current));
708                for projection in self.projections.values_mut() {
709                    let replaced = match &projection.target {
710                        ObservationTarget::Runtime { key } => {
711                            &key.node_id == node_id && key.incarnation_id == current
712                        }
713                        ObservationTarget::Managed { key } => {
714                            &key.node_id == node_id && key.incarnation_id == current
715                        }
716                    };
717                    if replaced {
718                        projection.availability = ProjectionAvailability::Frozen;
719                        projection.freshness = ProjectionFreshness::ReplacedIncarnation;
720                    }
721                }
722                for (key, runtime) in self.managed_links.iter_mut() {
723                    if &key.node_id == node_id && key.incarnation_id == current {
724                        *runtime = None;
725                    }
726                }
727                self.current_incarnations.insert(node_id.clone(), incarnation_id);
728            }
729        }
730        Ok(())
731    }
732
733    fn apply_payload(
734        &mut self,
735        envelope: &ObservationIngressEnvelope,
736    ) -> Result<(), ObservationEngineError> {
737        match &envelope.payload {
738            ObservationIngressPayload::Observations {
739                address,
740                observations,
741            } => {
742                // A runtime session's observations also belong to every
743                // managed record linked to it, exactly as if the record's own
744                // stream had carried them.
745                let mut targets = vec![address.clone()];
746                if let ObservationTarget::Runtime { key } = address {
747                    targets.extend(
748                        self.managed_links
749                            .iter()
750                            .filter(|(_, runtime)| runtime.as_ref() == Some(key))
751                            .map(|(managed, _)| ObservationTarget::Managed {
752                                key: managed.clone(),
753                            }),
754                    );
755                }
756                for target in targets {
757                    let projection = self.ensure_projection(target)?;
758                    projection.mark_observed();
759                    for observation in observations {
760                        apply_observation(
761                            projection,
762                            envelope.cursor,
763                            envelope.received_at_ms,
764                            observation,
765                        )?;
766                    }
767                }
768            }
769            ObservationIngressPayload::ManagedRecordUpserted { link } => {
770                self.upsert_managed_link(link.clone())?;
771            }
772            ObservationIngressPayload::ManagedRecordRemoved { key } => {
773                self.remove_managed_link(key);
774            }
775            ObservationIngressPayload::ResyncRequired { .. } => {
776                self.mark_node_gap(&envelope.node_id, envelope.cursor.incarnation_id);
777            }
778            ObservationIngressPayload::CursorOnly => {}
779        }
780        Ok(())
781    }
782
783    /// A projection can never receive another observation once its
784    /// target's incarnation is no longer the node's current one --
785    /// `observe_incarnation` rejects any envelope still addressed to a
786    /// superseded incarnation with `ReplacedIncarnation`, so nothing ever
787    /// writes to it again. `current_incarnations` is the authority for
788    /// "current"; a node absent from it (should not happen for a target
789    /// that already has a projection, but kept as a conservative fallback)
790    /// proves nothing, so its projections are left alone rather than
791    /// guessed dead.
792    fn projection_is_superseded(&self, target: &ObservationTarget) -> bool {
793        match self.current_incarnations.get(target.node_id()) {
794            Some(current) => *current != target.incarnation_id(),
795            None => false,
796        }
797    }
798
799    /// Removes every session projection whose target incarnation is
800    /// superseded, freeing room without ever raising `PROJECTIONS_MAX`.
801    /// Runs on checkpoint load, so a store already at the cap comes back
802    /// usable, and again from `ensure_projection` right before it would
803    /// otherwise report `CapacityExhausted` -- making room for a fresh
804    /// target instead of bricking on stale ones. A projection for a
805    /// node's CURRENT incarnation is never a candidate: live or idle, the
806    /// harness-MCP door's credential binding needs it to keep existing.
807    /// Logs one summary line per pass -- the same shape the node's own
808    /// session-record retention sweep logs -- because silence here is how
809    /// the unbounded growth went unnoticed until it bricked the harness.
810    fn retire_superseded_projections(&mut self, reason: &'static str) -> usize {
811        let dead = self
812            .projections
813            .keys()
814            .filter(|target| self.projection_is_superseded(target))
815            .cloned()
816            .collect::<Vec<_>>();
817        let retired = dead.len();
818        for target in &dead {
819            self.projections.remove(target);
820        }
821        tracing::info!(
822            retired,
823            remaining = self.projections.len(),
824            reason,
825            criterion = "target incarnation superseded by the node's current incarnation",
826            "session projection retirement pass completed",
827        );
828        retired
829    }
830
831    fn ensure_projection(
832        &mut self,
833        target: ObservationTarget,
834    ) -> Result<&mut SessionProjection, ObservationEngineError> {
835        if !self.projections.contains_key(&target) {
836            if self.projections.len() == PROJECTIONS_MAX {
837                self.retire_superseded_projections("capacity pressure");
838                if self.projections.len() == PROJECTIONS_MAX {
839                    return Err(ObservationEngineError::CapacityExhausted {
840                        collection: "session projections",
841                        max: PROJECTIONS_MAX,
842                    });
843                }
844            }
845            let mut projection = SessionProjection::new(target.clone());
846            if self.transport_gaps.contains(&(
847                target.node_id().clone(),
848                target.incarnation_id(),
849            )) {
850                projection.mark_transport_gap();
851            }
852            self.projections.insert(target.clone(), projection);
853        }
854        Ok(self.projections.get_mut(&target).expect("projection was inserted"))
855    }
856
857    fn upsert_managed_link(
858        &mut self,
859        link: ManagedRecordLink,
860    ) -> Result<(), ObservationEngineError> {
861        let target = ObservationTarget::Managed {
862            key: link.managed.clone(),
863        };
864        let projection = self.ensure_projection(target)?;
865        projection.mark_inventory_present(link.runtime.is_some());
866        self.managed_links.insert(link.managed, link.runtime);
867        Ok(())
868    }
869
870    fn remove_managed_link(&mut self, key: &ManagedSessionKey) {
871        self.managed_links.remove(key);
872        let target = ObservationTarget::Managed { key: key.clone() };
873        if let Some(projection) = self.projections.get_mut(&target) {
874            projection.mark_inventory_absent();
875        }
876    }
877
878    fn apply_resync_records(
879        &mut self,
880        batch: &ObservationResyncBatch,
881    ) -> Result<(), ObservationEngineError> {
882        self.apply_record_inventory(
883            &batch.node_id,
884            batch.incarnation_id,
885            &batch.records,
886            batch.records_complete,
887        )
888    }
889
890    fn apply_record_inventory(
891        &mut self,
892        node_id: &NodeId,
893        incarnation_id: NodeIncarnationId,
894        records: &[ManagedRecordLink],
895        complete: bool,
896    ) -> Result<(), ObservationEngineError> {
897        let present: HashSet<_> = records.iter().map(|link| link.managed.clone()).collect();
898        if complete {
899            let absent: Vec<_> = self
900                .projections
901                .keys()
902                .filter_map(|target| match target {
903                    ObservationTarget::Managed { key }
904                        if &key.node_id == node_id
905                            && key.incarnation_id == incarnation_id
906                            && !present.contains(key) => Some(key.clone()),
907                    _ => None,
908                })
909                .collect();
910            for key in absent {
911                self.managed_links.remove(&key);
912                let target = ObservationTarget::Managed { key: key.clone() };
913                if let Some(projection) = self.projections.get_mut(&target) {
914                    projection.mark_inventory_absent();
915                }
916            }
917        }
918        for link in records {
919            self.upsert_managed_link(link.clone())?;
920        }
921        Ok(())
922    }
923
924    fn mark_node_gap(&mut self, node_id: &NodeId, incarnation_id: NodeIncarnationId) {
925        self.transport_gaps.insert((node_id.clone(), incarnation_id));
926        for projection in self.projections.values_mut() {
927            let belongs = match &projection.target {
928                ObservationTarget::Runtime { key } => {
929                    &key.node_id == node_id && key.incarnation_id == incarnation_id
930                }
931                ObservationTarget::Managed { key } => {
932                    &key.node_id == node_id
933                        && key.incarnation_id == incarnation_id
934                }
935            };
936            if belongs && projection.availability != ProjectionAvailability::Frozen {
937                projection.mark_transport_gap();
938            }
939        }
940    }
941
942    fn clear_node_transport_gap(&mut self, node_id: &NodeId, incarnation_id: NodeIncarnationId) {
943        self.transport_gaps.remove(&(node_id.clone(), incarnation_id));
944        for projection in self.projections.values_mut() {
945            let belongs = match &projection.target {
946                ObservationTarget::Runtime { key } => {
947                    &key.node_id == node_id && key.incarnation_id == incarnation_id
948                }
949                ObservationTarget::Managed { key } => {
950                    &key.node_id == node_id
951                        && key.incarnation_id == incarnation_id
952                }
953            };
954            if belongs {
955                projection.transport_incomplete = false;
956                projection.refresh_integrity();
957            }
958        }
959    }
960
961    fn record_high_watermark(
962        &mut self,
963        node_id: &NodeId,
964        incarnation_id: NodeIncarnationId,
965        sequence: u64,
966    ) {
967        let high = self
968            .high_watermarks
969            .entry((node_id.clone(), incarnation_id))
970            .or_insert(0);
971        *high = (*high).max(sequence);
972    }
973
974    fn compact_cursor_journal(&mut self) {
975        if self.journal.len() <= CURSOR_JOURNAL_CHECKPOINT_RETAINED_MAX {
976            return;
977        }
978        let evicted_count = self.journal.len() - CURSOR_JOURNAL_CHECKPOINT_RETAINED_MAX;
979        let mut keys = self.journal.keys().cloned().collect::<Vec<_>>();
980        keys.sort_by(|left, right| {
981            left.sequence.cmp(&right.sequence)
982                .then_with(|| left.node_id.cmp(&right.node_id))
983                .then_with(|| left.incarnation_id.cmp(&right.incarnation_id))
984        });
985        for key in keys.into_iter().take(evicted_count) {
986            let floor = self
987                .retention_floors
988                .entry((key.node_id.clone(), key.incarnation_id))
989                .or_insert(0);
990            *floor = (*floor).max(key.sequence);
991            self.journal.remove(&key);
992        }
993    }
994
995    fn to_checkpoint(&self) -> ObservationEngineCheckpointV1 {
996        let mut projections = self.projections.values().cloned().collect::<Vec<_>>();
997        projections.sort_by(|left, right| target_sort_key(&left.target).cmp(&target_sort_key(&right.target)));
998
999        let mut cursor_journal = self.journal.values().map(|entry| {
1000            ObservationCheckpointCursorV1 {
1001                node_id: entry.node_id.clone(),
1002                cursor: entry.cursor,
1003                payload: entry.payload.clone(),
1004            }
1005        }).collect::<Vec<_>>();
1006        cursor_journal.sort_by(route_cursor_order);
1007
1008        let mut current_incarnations = self.current_incarnations.iter().map(|(node_id, incarnation_id)| {
1009            ObservationCheckpointRouteV1 { node_id: node_id.clone(), incarnation_id: *incarnation_id }
1010        }).collect::<Vec<_>>();
1011        current_incarnations.sort_by(route_order);
1012        let mut retired_incarnations = self.retired_incarnations.iter().map(|(node_id, incarnation_id)| {
1013            ObservationCheckpointRouteV1 { node_id: node_id.clone(), incarnation_id: *incarnation_id }
1014        }).collect::<Vec<_>>();
1015        retired_incarnations.sort_by(route_order);
1016        let mut managed_links = self.managed_links.iter().map(|(managed, runtime)| ManagedRecordLink {
1017            managed: managed.clone(), runtime: runtime.clone(),
1018        }).collect::<Vec<_>>();
1019        managed_links.sort_by(|left, right| {
1020            left.managed.node_id.cmp(&right.managed.node_id)
1021                .then_with(|| left.managed.incarnation_id.cmp(&right.managed.incarnation_id))
1022                .then_with(|| left.managed.record_id.cmp(&right.managed.record_id))
1023        });
1024        let mut transport_gaps = self.transport_gaps.iter().map(|(node_id, incarnation_id)| {
1025            ObservationCheckpointRouteV1 { node_id: node_id.clone(), incarnation_id: *incarnation_id }
1026        }).collect::<Vec<_>>();
1027        transport_gaps.sort_by(route_order);
1028        let mut retention_floors = route_cursor_entries(&self.retention_floors);
1029        retention_floors.sort_by(route_checkpoint_cursor_order);
1030        let mut high_watermarks = route_cursor_entries(&self.high_watermarks);
1031        high_watermarks.sort_by(route_checkpoint_cursor_order);
1032
1033        ObservationEngineCheckpointV1 {
1034            version: OBSERVATION_ENGINE_CHECKPOINT_VERSION_V1,
1035            projections,
1036            cursor_journal,
1037            current_incarnations,
1038            retired_incarnations,
1039            managed_links,
1040            transport_gaps,
1041            retention_floors,
1042            high_watermarks,
1043        }
1044    }
1045}
1046
1047#[derive(Clone, Debug)]
1048pub struct PreparedBatch {
1049    next: ObservationEngine,
1050    outcomes: Vec<ApplyOutcome>,
1051}
1052
1053impl PreparedBatch {
1054    pub fn outcomes(&self) -> &[ApplyOutcome] {
1055        &self.outcomes
1056    }
1057
1058    pub fn compact_for_checkpoint(&mut self) -> ObservationEngineCheckpointV1 {
1059        self.next.compact_cursor_journal();
1060        self.next.to_checkpoint()
1061    }
1062
1063    pub fn checkpoint(&self) -> ObservationEngineCheckpointV1 {
1064        self.next.to_checkpoint()
1065    }
1066}
1067
1068impl ObservationEngineCheckpointV1 {
1069    pub fn validate(&self) -> Result<(), ObservationEngineError> {
1070        if self.version != OBSERVATION_ENGINE_CHECKPOINT_VERSION_V1 {
1071            return Err(ObservationEngineError::InvalidCheckpoint {
1072                detail: "unsupported checkpoint version",
1073            });
1074        }
1075        if self.projections.len() > PROJECTIONS_MAX
1076            || self.cursor_journal.len() > CURSOR_JOURNAL_MAX
1077            || self.current_incarnations.len() > NODE_ROUTES_MAX
1078            || self.retired_incarnations.len() > RETIRED_INCARNATIONS_MAX
1079            || self.managed_links.len() > PROJECTIONS_MAX
1080            || self.transport_gaps.len() > OBSERVATION_ROUTE_HISTORY_MAX
1081            || self.retention_floors.len() > OBSERVATION_ROUTE_HISTORY_MAX
1082            || self.high_watermarks.len() > OBSERVATION_ROUTE_HISTORY_MAX
1083        {
1084            return Err(ObservationEngineError::InvalidCheckpoint {
1085                detail: "checkpoint collection exceeds its bound",
1086            });
1087        }
1088
1089        let mut projection_targets = HashSet::new();
1090        for projection in &self.projections {
1091            projection.target.validate_route(
1092                projection.target.node_id(),
1093                projection.target.incarnation_id(),
1094            )?;
1095            if projection.timeline.len() > TIMELINE_PER_SESSION_MAX
1096                || projection.tools.len() > TOOLS_PER_SESSION_MAX
1097                || projection.subagents.len() > SUBAGENTS_PER_SESSION_MAX
1098                || projection.files.len() > FILES_PER_SESSION_MAX
1099                || projection.owned_processes.len() > OWNED_PROCESSES_PER_SESSION_MAX
1100                || projection.interactions.len() > INTERACTIONS_PER_SESSION_MAX
1101                || !projection_targets.insert(projection.target.clone())
1102            {
1103                return Err(ObservationEngineError::InvalidCheckpoint {
1104                    detail: "invalid or duplicate session projection",
1105                });
1106            }
1107        }
1108
1109        let mut journal_keys = HashSet::new();
1110        for entry in &self.cursor_journal {
1111            if entry.cursor.sequence == 0 {
1112                return Err(ObservationEngineError::InvalidCheckpoint {
1113                    detail: "zero checkpoint cursor",
1114                });
1115            }
1116            entry.payload.validate_route(&entry.node_id, entry.cursor)?;
1117            let key = (entry.node_id.clone(), entry.cursor.incarnation_id, entry.cursor.sequence);
1118            if !journal_keys.insert(key) {
1119                return Err(ObservationEngineError::InvalidCheckpoint {
1120                    detail: "duplicate checkpoint cursor",
1121                });
1122            }
1123        }
1124
1125        validate_unique_routes(&self.current_incarnations)?;
1126        validate_unique_routes(&self.retired_incarnations)?;
1127        validate_unique_routes(&self.transport_gaps)?;
1128        validate_unique_route_cursors(&self.retention_floors, false)?;
1129        validate_unique_route_cursors(&self.high_watermarks, true)?;
1130        let mut current_nodes = HashSet::new();
1131        if self.current_incarnations.iter().any(|route| {
1132            !current_nodes.insert(route.node_id.clone())
1133                || self.retired_incarnations.iter().any(|retired| {
1134                    retired.node_id == route.node_id
1135                        && retired.incarnation_id == route.incarnation_id
1136                })
1137        }) {
1138            return Err(ObservationEngineError::InvalidCheckpoint {
1139                detail: "current incarnation routes conflict",
1140            });
1141        }
1142
1143        let mut managed_keys = HashSet::new();
1144        for link in &self.managed_links {
1145            link.validate_route(&link.managed.node_id, link.managed.incarnation_id)?;
1146            if !managed_keys.insert(link.managed.clone()) {
1147                return Err(ObservationEngineError::InvalidCheckpoint {
1148                    detail: "duplicate managed link",
1149                });
1150            }
1151        }
1152
1153        for floor in &self.retention_floors {
1154            let high = self.high_watermarks.iter().find(|high| {
1155                high.node_id == floor.node_id
1156                    && high.cursor.incarnation_id == floor.cursor.incarnation_id
1157            });
1158            if floor.cursor.sequence == 0
1159                || high.is_none_or(|high| floor.cursor.sequence > high.cursor.sequence)
1160            {
1161                return Err(ObservationEngineError::InvalidCheckpoint {
1162                    detail: "retention floor exceeds durable high watermark",
1163                });
1164            }
1165        }
1166        for entry in &self.cursor_journal {
1167            let high = self.high_watermarks.iter().find(|high| {
1168                high.node_id == entry.node_id
1169                    && high.cursor.incarnation_id == entry.cursor.incarnation_id
1170            });
1171            let floor = self.retention_floors.iter().find(|floor| {
1172                floor.node_id == entry.node_id
1173                    && floor.cursor.incarnation_id == entry.cursor.incarnation_id
1174            });
1175            if high.is_none_or(|high| entry.cursor.sequence > high.cursor.sequence)
1176                || floor.is_some_and(|floor| entry.cursor.sequence <= floor.cursor.sequence)
1177            {
1178                return Err(ObservationEngineError::InvalidCheckpoint {
1179                    detail: "cursor journal is outside durable route bounds",
1180                });
1181            }
1182        }
1183        Ok(())
1184    }
1185}
1186
1187fn validate_unique_routes(
1188    routes: &[ObservationCheckpointRouteV1],
1189) -> Result<(), ObservationEngineError> {
1190    let mut unique = HashSet::new();
1191    if routes.iter().any(|route| !unique.insert((route.node_id.clone(), route.incarnation_id))) {
1192        return Err(ObservationEngineError::InvalidCheckpoint {
1193            detail: "duplicate checkpoint route",
1194        });
1195    }
1196    Ok(())
1197}
1198
1199fn validate_unique_route_cursors(
1200    routes: &[ObservationCheckpointRouteCursorV1],
1201    allow_zero: bool,
1202) -> Result<(), ObservationEngineError> {
1203    let mut unique = HashSet::new();
1204    if routes.iter().any(|route| {
1205        (!allow_zero && route.cursor.sequence == 0)
1206            || !unique.insert((route.node_id.clone(), route.cursor.incarnation_id))
1207    }) {
1208        return Err(ObservationEngineError::InvalidCheckpoint {
1209            detail: "invalid or duplicate checkpoint route cursor",
1210        });
1211    }
1212    Ok(())
1213}
1214
1215fn target_sort_key(target: &ObservationTarget) -> (NodeId, NodeIncarnationId, String) {
1216    match target {
1217        ObservationTarget::Runtime { key } => (
1218            key.node_id.clone(),
1219            key.incarnation_id,
1220            format!("runtime:{}:{}:{}", key.workspace_id, key.instance_id.0, key.generation.0),
1221        ),
1222        ObservationTarget::Managed { key } => (
1223            key.node_id.clone(),
1224            key.incarnation_id,
1225            format!("managed:{}", key.record_id),
1226        ),
1227    }
1228}
1229
1230fn route_order(
1231    left: &ObservationCheckpointRouteV1,
1232    right: &ObservationCheckpointRouteV1,
1233) -> std::cmp::Ordering {
1234    left.node_id.cmp(&right.node_id)
1235        .then_with(|| left.incarnation_id.cmp(&right.incarnation_id))
1236}
1237
1238fn route_cursor_order(
1239    left: &ObservationCheckpointCursorV1,
1240    right: &ObservationCheckpointCursorV1,
1241) -> std::cmp::Ordering {
1242    left.node_id.cmp(&right.node_id)
1243        .then_with(|| left.cursor.incarnation_id.cmp(&right.cursor.incarnation_id))
1244        .then_with(|| left.cursor.sequence.cmp(&right.cursor.sequence))
1245}
1246
1247fn route_checkpoint_cursor_order(
1248    left: &ObservationCheckpointRouteCursorV1,
1249    right: &ObservationCheckpointRouteCursorV1,
1250) -> std::cmp::Ordering {
1251    left.node_id.cmp(&right.node_id)
1252        .then_with(|| left.cursor.incarnation_id.cmp(&right.cursor.incarnation_id))
1253}
1254
1255fn route_cursor_entries(
1256    entries: &HashMap<(NodeId, NodeIncarnationId), u64>,
1257) -> Vec<ObservationCheckpointRouteCursorV1> {
1258    entries.iter().map(|((node_id, incarnation_id), sequence)| {
1259        ObservationCheckpointRouteCursorV1 {
1260            node_id: node_id.clone(),
1261            cursor: NodeCursor {
1262                incarnation_id: *incarnation_id,
1263                sequence: *sequence,
1264            },
1265        }
1266    }).collect()
1267}
1268
1269fn apply_observation(
1270    projection: &mut SessionProjection,
1271    cursor: NodeCursor,
1272    received_at_ms: u64,
1273    observation: &hatchery_observation_protocol::ObservationV1,
1274) -> Result<(), ObservationEngineError> {
1275    let evidence = observation.evidence;
1276    match &observation.kind {
1277        ObservationKindV1::SourceCapabilities {
1278            source_family,
1279            source_adapter,
1280            capabilities,
1281        } => {
1282            if let Some(current) = projection.source_capabilities.iter_mut().find(|current| {
1283                current.evidence == evidence
1284                    && current.source_family == *source_family
1285                    && current.source_adapter == *source_adapter
1286            }) {
1287                current.capabilities = *capabilities;
1288            } else {
1289                if projection.source_capabilities.len() == hatchery_observation_protocol::OBSERVATION_COLLECTION_MAX {
1290                    return Err(ObservationEngineError::CapacityExhausted {
1291                        collection: "source capabilities",
1292                        max: hatchery_observation_protocol::OBSERVATION_COLLECTION_MAX,
1293                    });
1294                }
1295                projection.source_capabilities.push(SourceCapabilitiesProjection {
1296                    evidence,
1297                    source_family: *source_family,
1298                    source_adapter: source_adapter.clone(),
1299                    capabilities: *capabilities,
1300                });
1301            }
1302        }
1303        ObservationKindV1::Stopped | ObservationKindV1::Exited { .. } => {
1304            projection.freshness = ProjectionFreshness::LastKnown;
1305        }
1306        ObservationKindV1::Stale => projection.mark_source_stale(evidence),
1307        ObservationKindV1::Error { .. }
1308            if evidence == ObservationEvidenceV1::HistoryProjection =>
1309        {
1310            projection.mark_source_stale(evidence)
1311        }
1312        ObservationKindV1::ToolStarted {
1313            correlation_id,
1314            class,
1315        } => start_correlation(
1316            &mut projection.tools,
1317            correlation_id,
1318            Some(class),
1319            evidence,
1320            TOOLS_PER_SESSION_MAX,
1321            "tools",
1322        )?,
1323        ObservationKindV1::ToolCompleted {
1324            correlation_id,
1325            class,
1326            success,
1327            ..
1328        } => complete_correlation(
1329            &mut projection.tools,
1330            correlation_id,
1331            Some(class),
1332            evidence,
1333            Some(*success),
1334            TOOLS_PER_SESSION_MAX,
1335            "tools",
1336        )?,
1337        ObservationKindV1::ApprovalRequested {
1338            correlation_id,
1339            tool_class,
1340        }
1341        | ObservationKindV1::QuestionRequested {
1342            correlation_id,
1343            tool_class,
1344        } => start_correlation(
1345            &mut projection.interactions,
1346            correlation_id,
1347            Some(tool_class),
1348            evidence,
1349            INTERACTIONS_PER_SESSION_MAX,
1350            "interactions",
1351        )?,
1352        ObservationKindV1::ApprovalResolved {
1353            correlation_id,
1354            outcome,
1355        }
1356        | ObservationKindV1::QuestionResolved {
1357            correlation_id,
1358            outcome,
1359        }
1360        | ObservationKindV1::InteractionResolved {
1361            correlation_id,
1362            outcome,
1363        } => resolve_correlation(
1364            &mut projection.interactions,
1365            correlation_id,
1366            evidence,
1367            *outcome,
1368            INTERACTIONS_PER_SESSION_MAX,
1369            "interactions",
1370        )?,
1371        ObservationKindV1::SubagentStarted {
1372            correlation_id,
1373            class,
1374        } => start_correlation(
1375            &mut projection.subagents,
1376            correlation_id,
1377            Some(class),
1378            evidence,
1379            SUBAGENTS_PER_SESSION_MAX,
1380            "subagents",
1381        )?,
1382        ObservationKindV1::SubagentProgress { correlation_id } => {
1383            start_correlation(
1384                &mut projection.subagents,
1385                correlation_id,
1386                None,
1387                evidence,
1388                SUBAGENTS_PER_SESSION_MAX,
1389                "subagents",
1390            )?;
1391        }
1392        ObservationKindV1::SubagentCompleted {
1393            correlation_id,
1394            success,
1395        } => complete_correlation(
1396            &mut projection.subagents,
1397            correlation_id,
1398            None,
1399            evidence,
1400            *success,
1401            SUBAGENTS_PER_SESSION_MAX,
1402            "subagents",
1403        )?,
1404        ObservationKindV1::OwnedProcessStarted {
1405            correlation_id,
1406            class,
1407        } => start_correlation(
1408            &mut projection.owned_processes,
1409            correlation_id,
1410            Some(class),
1411            evidence,
1412            OWNED_PROCESSES_PER_SESSION_MAX,
1413            "owned processes",
1414        )?,
1415        ObservationKindV1::OwnedProcessExited {
1416            correlation_id,
1417            success,
1418            ..
1419        } => complete_correlation(
1420            &mut projection.owned_processes,
1421            correlation_id,
1422            None,
1423            evidence,
1424            *success,
1425            OWNED_PROCESSES_PER_SESSION_MAX,
1426            "owned processes",
1427        )?,
1428        ObservationKindV1::TodoSnapshot {
1429            revision,
1430            items,
1431            complete,
1432        } => apply_todo(projection, *revision, items, *complete, evidence),
1433        ObservationKindV1::Usage {
1434            input_tokens,
1435            output_tokens,
1436            cache_read_tokens,
1437            cache_write_tokens,
1438            reasoning_tokens,
1439            context_window,
1440            is_cumulative,
1441        } => apply_usage(
1442            &mut projection.usage,
1443            UsageTotals {
1444                input_tokens: *input_tokens,
1445                output_tokens: *output_tokens,
1446                cache_read_tokens: *cache_read_tokens,
1447                cache_write_tokens: *cache_write_tokens,
1448                reasoning_tokens: *reasoning_tokens,
1449            },
1450            *context_window,
1451            *is_cumulative,
1452            evidence,
1453        ),
1454        ObservationKindV1::ContextWindowUsage {
1455            uncached_input_tokens,
1456            cache_read_tokens,
1457            cache_write_tokens,
1458            output_tokens,
1459            unattributed_tokens,
1460            used_tokens,
1461            capacity_tokens,
1462        } => apply_context_window_usage(
1463            &mut projection.usage,
1464            *uncached_input_tokens,
1465            *cache_read_tokens,
1466            *cache_write_tokens,
1467            *output_tokens,
1468            *unattributed_tokens,
1469            *used_tokens,
1470            *capacity_tokens,
1471            evidence,
1472        ),
1473        ObservationKindV1::FileChanged { path } => {
1474            if projection.files.len() == FILES_PER_SESSION_MAX {
1475                projection.files.pop_front();
1476            }
1477            projection.files.push_back(FileProjection {
1478                path: path.clone(),
1479                evidence,
1480            });
1481        }
1482        ObservationKindV1::HistorySnapshot {
1483            message_count,
1484            message_count_exact,
1485            completed_turn_count,
1486            total_tokens,
1487        } => {
1488            projection.history = Some(HistoryProjection {
1489                evidence,
1490                message_count: *message_count,
1491                message_count_exact: *message_count_exact,
1492                completed_turn_count: *completed_turn_count,
1493                total_tokens: *total_tokens,
1494            });
1495            projection.stale_evidence.retain(|current| *current != evidence);
1496            projection.refresh_integrity();
1497        }
1498        ObservationKindV1::Gap { .. } => projection.mark_source_incomplete(evidence, true),
1499        ObservationKindV1::SourceReset => projection.source_reset(evidence),
1500        ObservationKindV1::ActionBlocked { .. } => {
1501            projection.blocked_count = projection.blocked_count.saturating_add(1);
1502            projection.last_blocked_at_ms = Some(match projection.last_blocked_at_ms {
1503                Some(previous) => previous.max(received_at_ms),
1504                None => received_at_ms,
1505            });
1506        }
1507        _ => {}
1508    }
1509    if observation.truncated {
1510        projection.mark_source_incomplete(evidence, false);
1511    }
1512    if !matches!(observation.kind, ObservationKindV1::SourceCapabilities { .. }) {
1513        projection.push_timeline(TimelineEntry {
1514            cursor,
1515            received_at_ms,
1516            evidence,
1517            kind: observation.kind.clone(),
1518        });
1519    }
1520    Ok(())
1521}
1522
1523fn start_correlation(
1524    entries: &mut Vec<CorrelationProjection>,
1525    correlation_id: &str,
1526    class: Option<&String>,
1527    evidence: ObservationEvidenceV1,
1528    max: usize,
1529    collection: &'static str,
1530) -> Result<(), ObservationEngineError> {
1531    if let Some(existing) = entries
1532        .iter_mut()
1533        .find(|entry| entry.correlation_id == correlation_id)
1534    {
1535        if existing.state == CorrelationState::Pending && existing.class.is_none() {
1536            existing.class = class.cloned();
1537        }
1538        return Ok(());
1539    }
1540    ensure_correlation_capacity(entries, max, collection)?;
1541    entries.push(CorrelationProjection {
1542        correlation_id: correlation_id.to_owned(),
1543        class: class.cloned(),
1544        evidence,
1545        state: CorrelationState::Pending,
1546    });
1547    Ok(())
1548}
1549
1550fn complete_correlation(
1551    entries: &mut Vec<CorrelationProjection>,
1552    correlation_id: &str,
1553    class: Option<&String>,
1554    evidence: ObservationEvidenceV1,
1555    success: Option<bool>,
1556    max: usize,
1557    collection: &'static str,
1558) -> Result<(), ObservationEngineError> {
1559    if let Some(existing) = entries
1560        .iter_mut()
1561        .find(|entry| entry.correlation_id == correlation_id)
1562    {
1563        if existing.state == CorrelationState::Pending {
1564            existing.state = CorrelationState::Completed { success };
1565            if existing.class.is_none() {
1566                existing.class = class.cloned();
1567            }
1568        }
1569        return Ok(());
1570    }
1571    ensure_correlation_capacity(entries, max, collection)?;
1572    entries.push(CorrelationProjection {
1573        correlation_id: correlation_id.to_owned(),
1574        class: class.cloned(),
1575        evidence,
1576        state: CorrelationState::OrphanCompletion { success },
1577    });
1578    Ok(())
1579}
1580
1581fn resolve_correlation(
1582    entries: &mut Vec<CorrelationProjection>,
1583    correlation_id: &str,
1584    evidence: ObservationEvidenceV1,
1585    outcome: ObservationInteractionOutcomeV1,
1586    max: usize,
1587    collection: &'static str,
1588) -> Result<(), ObservationEngineError> {
1589    if let Some(existing) = entries
1590        .iter_mut()
1591        .find(|entry| entry.correlation_id == correlation_id)
1592    {
1593        if existing.state == CorrelationState::Pending {
1594            existing.state = CorrelationState::Resolved { outcome };
1595        }
1596        return Ok(());
1597    }
1598    ensure_correlation_capacity(entries, max, collection)?;
1599    entries.push(CorrelationProjection {
1600        correlation_id: correlation_id.to_owned(),
1601        class: None,
1602        evidence,
1603        state: CorrelationState::OrphanResolution { outcome },
1604    });
1605    Ok(())
1606}
1607
1608fn ensure_correlation_capacity(
1609    entries: &mut Vec<CorrelationProjection>,
1610    max: usize,
1611    collection: &'static str,
1612) -> Result<(), ObservationEngineError> {
1613    if entries.len() < max {
1614        return Ok(());
1615    }
1616    if let Some(index) = entries.iter().position(|entry| !entry.state.is_active()) {
1617        entries.remove(index);
1618        return Ok(());
1619    }
1620    Err(ObservationEngineError::CapacityExhausted { collection, max })
1621}
1622
1623fn apply_todo(
1624    projection: &mut SessionProjection,
1625    revision: u64,
1626    items: &[ObservationTodoItemV1],
1627    complete: bool,
1628    evidence: ObservationEvidenceV1,
1629) {
1630    let incoming = TodoRevisionProjection {
1631        revision,
1632        items: items.to_vec(),
1633        complete,
1634        evidence,
1635    };
1636    match projection.todos.current.as_ref() {
1637        None => projection.todos.current = Some(incoming),
1638        Some(current) if revision > current.revision => {
1639            projection.todos.previous = projection.todos.current.replace(incoming);
1640            projection.todos.conflict = false;
1641        }
1642        Some(current) if revision == current.revision && current == &incoming => {}
1643        Some(current) if revision == current.revision => {
1644            projection.todos.conflict = true;
1645            projection.mark_source_incomplete(evidence, false);
1646        }
1647        Some(_) => {
1648            projection.todos.conflict = true;
1649            projection.mark_source_incomplete(evidence, false);
1650        }
1651    }
1652}
1653
1654fn apply_usage(
1655    projection: &mut UsageProjection,
1656    incoming: UsageTotals,
1657    context_window: Option<u64>,
1658    is_cumulative: bool,
1659    evidence: ObservationEvidenceV1,
1660) {
1661    projection.context_window = context_window.or(projection.context_window);
1662    let previous = projection
1663        .context_occupancy
1664        .filter(|snapshot| snapshot.evidence == evidence);
1665    let occupancy_totals = if is_cumulative {
1666        incoming
1667    } else {
1668        UsageTotals {
1669            input_tokens: incoming.input_tokens,
1670            output_tokens: previous
1671                .and_then(|snapshot| snapshot.output_tokens)
1672                .unwrap_or(0)
1673                .saturating_add(incoming.output_tokens),
1674            cache_read_tokens: incoming.cache_read_tokens,
1675            cache_write_tokens: incoming.cache_write_tokens,
1676            reasoning_tokens: incoming.reasoning_tokens,
1677        }
1678    };
1679    projection.context_occupancy = Some(ContextOccupancySnapshot {
1680        uncached_input_tokens: Some(occupancy_totals.input_tokens),
1681        output_tokens: Some(occupancy_totals.output_tokens),
1682        cache_read_tokens: Some(occupancy_totals.cache_read_tokens),
1683        cache_write_tokens: Some(occupancy_totals.cache_write_tokens),
1684        reasoning_tokens: Some(occupancy_totals.reasoning_tokens),
1685        unattributed_tokens: Some(0),
1686        used_tokens: occupancy_totals
1687            .input_tokens
1688            .saturating_add(occupancy_totals.cache_read_tokens)
1689            .saturating_add(occupancy_totals.cache_write_tokens)
1690            .saturating_add(occupancy_totals.output_tokens),
1691        context_window: context_window.or_else(|| previous.and_then(|snapshot| snapshot.context_window)),
1692        evidence,
1693        provenance: if is_cumulative {
1694            ContextOccupancyProvenance::CumulativeUsage
1695        } else {
1696            ContextOccupancyProvenance::PerTurnUsageSynthesis
1697        },
1698    });
1699    if !is_cumulative {
1700        projection.observed_delta = projection.observed_delta.saturating_add(incoming);
1701        return;
1702    }
1703    if projection.cumulative_evidence == Some(evidence) {
1704        if let Some(previous) = projection.last_cumulative {
1705            if let Some(delta) = incoming.checked_delta(previous) {
1706                projection.observed_delta = projection.observed_delta.saturating_add(delta);
1707            }
1708        }
1709    }
1710    projection.last_cumulative = Some(incoming);
1711    projection.cumulative_evidence = Some(evidence);
1712}
1713
1714fn apply_context_window_usage(
1715    projection: &mut UsageProjection,
1716    uncached_input_tokens: Option<u64>,
1717    cache_read_tokens: Option<u64>,
1718    cache_write_tokens: Option<u64>,
1719    output_tokens: Option<u64>,
1720    unattributed_tokens: Option<u64>,
1721    used_tokens: u64,
1722    capacity_tokens: u64,
1723    evidence: ObservationEvidenceV1,
1724) {
1725    if evidence == ObservationEvidenceV1::StructuredProvider {
1726        projection.context_occupancy = Some(ContextOccupancySnapshot {
1727            uncached_input_tokens,
1728            output_tokens,
1729            cache_read_tokens,
1730            cache_write_tokens,
1731            reasoning_tokens: None,
1732            unattributed_tokens,
1733            used_tokens,
1734            context_window: Some(capacity_tokens),
1735            evidence,
1736            provenance: ContextOccupancyProvenance::ExactCurrentWindow,
1737        });
1738    } else if projection.context_occupancy.is_some_and(|snapshot| {
1739        snapshot.provenance == ContextOccupancyProvenance::ExactCurrentWindow
1740    }) {
1741        projection.context_occupancy = None;
1742    }
1743}
1744
1745#[cfg(test)]
1746mod context_occupancy_internal_tests {
1747    use super::*;
1748
1749    fn apply_exact(projection: &mut UsageProjection, evidence: ObservationEvidenceV1) {
1750        apply_context_window_usage(
1751            projection,
1752            Some(10),
1753            Some(20),
1754            Some(5),
1755            Some(15),
1756            Some(10),
1757            60,
1758            100,
1759            evidence,
1760        );
1761    }
1762
1763    #[test]
1764    fn non_structured_exact_context_cannot_activate_or_survive_in_reducer() {
1765        let mut projection = UsageProjection {
1766            observed_delta: UsageTotals {
1767                input_tokens: 3,
1768                output_tokens: 2,
1769                ..UsageTotals::default()
1770            },
1771            ..UsageProjection::default()
1772        };
1773        let observed_delta = projection.observed_delta;
1774        for evidence in [
1775            ObservationEvidenceV1::ManagedHook,
1776            ObservationEvidenceV1::NodeLifecycle,
1777            ObservationEvidenceV1::WorkspaceObservation,
1778            ObservationEvidenceV1::HistoryProjection,
1779            ObservationEvidenceV1::PtyHint,
1780        ] {
1781            apply_exact(&mut projection, evidence);
1782            assert!(projection.context_occupancy.is_none(), "{evidence:?}");
1783            assert_eq!(projection.observed_delta, observed_delta, "{evidence:?}");
1784
1785            apply_exact(&mut projection, ObservationEvidenceV1::StructuredProvider);
1786            assert!(projection.context_occupancy.is_some());
1787            apply_exact(&mut projection, evidence);
1788            assert!(projection.context_occupancy.is_none(), "{evidence:?}");
1789            assert_eq!(projection.observed_delta, observed_delta, "{evidence:?}");
1790        }
1791    }
1792
1793    /// `ProviderEvent::UsageUpdated` only ever carries `used_tokens`/
1794    /// `context_window` -- the five breakdown segments arrive as `None`
1795    /// and must stay `None` through the reducer rather than being
1796    /// defaulted to zero, which would assert a segment was measured
1797    /// empty when it was never observed. `used_tokens`/`capacity_tokens`
1798    /// are still an exact current-window reading either way, so the
1799    /// provenance is the same `ExactCurrentWindow` the fully-observed
1800    /// breakdown gets.
1801    #[test]
1802    fn unobserved_breakdown_propagates_as_none_not_zero() {
1803        let mut projection = UsageProjection::default();
1804        apply_context_window_usage(
1805            &mut projection,
1806            None,
1807            None,
1808            None,
1809            None,
1810            None,
1811            4_200,
1812            200_000,
1813            ObservationEvidenceV1::StructuredProvider,
1814        );
1815        let snapshot = projection
1816            .context_occupancy
1817            .expect("structured provider activates exact-window occupancy");
1818        assert_eq!(snapshot.uncached_input_tokens, None);
1819        assert_eq!(snapshot.cache_read_tokens, None);
1820        assert_eq!(snapshot.cache_write_tokens, None);
1821        assert_eq!(snapshot.output_tokens, None);
1822        assert_eq!(snapshot.unattributed_tokens, None);
1823        assert_eq!(snapshot.reasoning_tokens, None);
1824        assert_eq!(snapshot.used_tokens, 4_200);
1825        assert_eq!(snapshot.context_window, Some(200_000));
1826        assert_eq!(snapshot.provenance, ContextOccupancyProvenance::ExactCurrentWindow);
1827    }
1828}
1829
1830#[derive(Clone, Debug, Eq, Error, PartialEq)]
1831pub enum ObservationEngineError {
1832    #[error(transparent)]
1833    Api(#[from] ObservationApiError),
1834    #[error("node {node_id} cursor {cursor:?} was reused with different canonical ingress")]
1835    CursorCollision { node_id: NodeId, cursor: NodeCursor },
1836    #[error("node {node_id} cursor {cursor:?} is at or below the durable retention floor")]
1837    BelowRetentionFloor { node_id: NodeId, cursor: NodeCursor },
1838    #[error("node {node_id} incarnation {incarnation_id} was already replaced")]
1839    ReplacedIncarnation {
1840        node_id: NodeId,
1841        incarnation_id: NodeIncarnationId,
1842    },
1843    #[error("active {collection} capacity {max} is exhausted")]
1844    CapacityExhausted {
1845        collection: &'static str,
1846        max: usize,
1847    },
1848    #[error("invalid observation engine checkpoint: {detail}")]
1849    InvalidCheckpoint { detail: &'static str },
1850}
1851
1852pub fn gap(first_sequence: u64, last_sequence: u64) -> ObservationGap {
1853    ObservationGap {
1854        first_sequence,
1855        last_sequence,
1856    }
1857}