1use hatchery_observation_protocol::{ObservationKindV1, ObservationV1};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10pub use gate4agent_node_protocol::{
11 NodeCursor, NodeId, NodeIncarnationId, SessionRecordId, WorkspaceId,
12};
13pub use gate4agent_types::{AgentInstanceId, SessionGeneration};
14
15pub const PROJECTIONS_MAX: usize = 8_192;
16pub const TIMELINE_PER_SESSION_MAX: usize = 512;
17pub const TOOLS_PER_SESSION_MAX: usize = 128;
18pub const SUBAGENTS_PER_SESSION_MAX: usize = 128;
19pub const FILES_PER_SESSION_MAX: usize = 128;
20pub const OWNED_PROCESSES_PER_SESSION_MAX: usize = 128;
21pub const INTERACTIONS_PER_SESSION_MAX: usize = 64;
22pub const INGRESS_BATCH_MAX: usize = 8_192;
23pub const OBSERVATIONS_PER_INGRESS_MAX: usize = 16;
26pub const CURSOR_JOURNAL_MAX: usize = 65_536;
27pub const NODE_ROUTES_MAX: usize = PROJECTIONS_MAX;
28pub const RETIRED_INCARNATIONS_MAX: usize = CURSOR_JOURNAL_MAX;
29pub const RESYNC_RECORDS_MAX: usize = PROJECTIONS_MAX;
30pub const RESYNC_GAPS_MAX: usize = 512;
31pub const RESYNC_EVENTS_MAX: usize = PROJECTIONS_MAX;
32
33#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
34#[serde(deny_unknown_fields)]
35pub struct RuntimeSessionKey {
36 pub node_id: NodeId,
37 pub incarnation_id: NodeIncarnationId,
38 pub workspace_id: WorkspaceId,
39 pub instance_id: AgentInstanceId,
40 pub generation: SessionGeneration,
41}
42
43impl RuntimeSessionKey {
44 pub fn validate(&self) -> Result<(), ObservationApiError> {
45 if self.instance_id.0 == 0 {
46 return Err(ObservationApiError::ZeroAgentInstanceId);
47 }
48 Ok(())
49 }
50}
51
52#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
53#[serde(deny_unknown_fields)]
54pub struct ManagedSessionKey {
55 pub node_id: NodeId,
56 pub incarnation_id: NodeIncarnationId,
57 pub record_id: SessionRecordId,
58}
59
60#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
61#[serde(tag = "target", rename_all = "kebab-case", deny_unknown_fields)]
62pub enum ObservationTarget {
63 Runtime { key: RuntimeSessionKey },
64 Managed { key: ManagedSessionKey },
65}
66
67impl ObservationTarget {
68 pub fn node_id(&self) -> &NodeId {
69 match self {
70 Self::Runtime { key } => &key.node_id,
71 Self::Managed { key } => &key.node_id,
72 }
73 }
74
75 pub fn runtime_key(&self) -> Option<&RuntimeSessionKey> {
76 match self {
77 Self::Runtime { key } => Some(key),
78 Self::Managed { .. } => None,
79 }
80 }
81
82 pub fn incarnation_id(&self) -> NodeIncarnationId {
83 match self {
84 Self::Runtime { key } => key.incarnation_id,
85 Self::Managed { key } => key.incarnation_id,
86 }
87 }
88
89 pub fn validate_route(
90 &self,
91 node_id: &NodeId,
92 incarnation_id: NodeIncarnationId,
93 ) -> Result<(), ObservationApiError> {
94 if self.node_id() != node_id {
95 return Err(ObservationApiError::RouteNodeMismatch);
96 }
97 if self.incarnation_id() != incarnation_id {
98 return Err(ObservationApiError::RouteIncarnationMismatch);
99 }
100 if let Some(runtime) = self.runtime_key() {
101 runtime.validate()?;
102 }
103 Ok(())
104 }
105}
106
107#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
108#[serde(deny_unknown_fields)]
109pub struct ManagedRecordLink {
110 pub managed: ManagedSessionKey,
111 pub runtime: Option<RuntimeSessionKey>,
112}
113
114impl ManagedRecordLink {
115 pub fn validate_route(
116 &self,
117 node_id: &NodeId,
118 incarnation_id: NodeIncarnationId,
119 ) -> Result<(), ObservationApiError> {
120 if &self.managed.node_id != node_id {
121 return Err(ObservationApiError::RouteNodeMismatch);
122 }
123 if self.managed.incarnation_id != incarnation_id {
124 return Err(ObservationApiError::RouteIncarnationMismatch);
125 }
126 if let Some(runtime) = self.runtime.as_ref() {
127 runtime.validate()?;
128 if &runtime.node_id != node_id {
129 return Err(ObservationApiError::RouteNodeMismatch);
130 }
131 if runtime.incarnation_id != incarnation_id {
132 return Err(ObservationApiError::RouteIncarnationMismatch);
133 }
134 }
135 Ok(())
136 }
137}
138
139#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
140#[serde(rename_all = "kebab-case")]
141pub enum ObservationTransport {
142 DirectNode,
143 C2,
144}
145
146#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
153pub struct ObservationSupport {
154 pub events: bool,
155 pub managed_target: bool,
156 pub workflow_detail: bool,
157}
158
159impl ObservationSupport {
160 pub const fn full() -> Self {
162 Self { events: true, managed_target: true, workflow_detail: true }
163 }
164}
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
168pub enum ObservationIngressPayload {
169 Observations {
173 address: ObservationTarget,
174 observations: Vec<ObservationV1>,
175 },
176 ManagedRecordUpserted {
177 link: ManagedRecordLink,
178 },
179 ManagedRecordRemoved {
180 key: ManagedSessionKey,
181 },
182 ResyncRequired {
183 oldest: NodeCursor,
184 },
185 CursorOnly,
186}
187
188impl ObservationIngressPayload {
189 pub fn validate_route(
190 &self,
191 node_id: &NodeId,
192 cursor: NodeCursor,
193 ) -> Result<(), ObservationApiError> {
194 match self {
195 Self::Observations {
196 address,
197 observations,
198 } => {
199 address.validate_route(node_id, cursor.incarnation_id)?;
200 validate_count(
201 "observations per ingress",
202 observations.len(),
203 OBSERVATIONS_PER_INGRESS_MAX,
204 )?;
205 for observation in observations {
206 observation.validate().map_err(ObservationApiError::Observation)?;
207 if let ObservationKindV1::Error { detail } = &observation.kind {
208 validate_error_category(detail)?;
209 }
210 }
211 Ok(())
212 }
213 Self::ManagedRecordUpserted { link } => {
214 link.validate_route(node_id, cursor.incarnation_id)
215 }
216 Self::ManagedRecordRemoved { key } => {
217 if &key.node_id != node_id {
218 return Err(ObservationApiError::RouteNodeMismatch);
219 }
220 if key.incarnation_id != cursor.incarnation_id {
221 return Err(ObservationApiError::RouteIncarnationMismatch);
222 }
223 Ok(())
224 }
225 Self::ResyncRequired { oldest } => {
226 if oldest.incarnation_id != cursor.incarnation_id {
227 return Err(ObservationApiError::RouteIncarnationMismatch);
228 }
229 if oldest.sequence == 0 || oldest.sequence > cursor.sequence {
230 return Err(ObservationApiError::InvalidOldestCursor);
231 }
232 Ok(())
233 }
234 Self::CursorOnly => Ok(()),
235 }
236 }
237}
238
239#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
240#[serde(deny_unknown_fields)]
241pub struct ObservationIngressEnvelope {
242 pub node_id: NodeId,
243 pub cursor: NodeCursor,
244 pub received_at_ms: u64,
245 pub transport: ObservationTransport,
246 pub payload: ObservationIngressPayload,
247}
248
249impl ObservationIngressEnvelope {
250 pub fn validate(&self) -> Result<(), ObservationApiError> {
251 if self.cursor.sequence == 0 {
252 return Err(ObservationApiError::ZeroEventSequence);
253 }
254 if self.received_at_ms == 0 {
255 return Err(ObservationApiError::ZeroReceivedAt);
256 }
257 self.payload.validate_route(&self.node_id, self.cursor)
258 }
259
260 pub fn canonical_eq(&self, other: &Self) -> bool {
265 self.node_id == other.node_id
266 && self.cursor == other.cursor
267 && self.payload == other.payload
268 }
269}
270
271#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(deny_unknown_fields)]
273pub struct ObservationGap {
274 pub first_sequence: u64,
275 pub last_sequence: u64,
276}
277
278impl ObservationGap {
279 pub fn validate(&self, requested_after: u64, high_watermark: u64) -> Result<(), ObservationApiError> {
280 if self.first_sequence == 0
281 || self.first_sequence > self.last_sequence
282 || self.first_sequence <= requested_after
283 || self.last_sequence > high_watermark
284 {
285 return Err(ObservationApiError::InvalidGap);
286 }
287 Ok(())
288 }
289}
290
291#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
292#[serde(deny_unknown_fields)]
293pub struct ObservationResyncBatch {
294 pub node_id: NodeId,
295 pub incarnation_id: NodeIncarnationId,
296 pub requested_after: u64,
297 pub high_watermark: NodeCursor,
298 pub oldest_available_sequence: u64,
301 pub records: Vec<ManagedRecordLink>,
302 pub records_complete: bool,
303 pub gaps: Vec<ObservationGap>,
306 pub events: Vec<ObservationIngressEnvelope>,
307}
308
309#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
314#[serde(deny_unknown_fields)]
315pub struct ObservationRecordInventory {
316 pub node_id: NodeId,
317 pub incarnation_id: NodeIncarnationId,
318 pub records: Vec<ManagedRecordLink>,
319 pub complete: bool,
320}
321
322impl ObservationRecordInventory {
323 pub fn validate(&self) -> Result<(), ObservationApiError> {
324 validate_count("managed record inventory", self.records.len(), RESYNC_RECORDS_MAX)?;
325 for record in &self.records {
326 record.validate_route(&self.node_id, self.incarnation_id)?;
327 }
328 Ok(())
329 }
330}
331
332impl ObservationResyncBatch {
333 pub fn validate(&self) -> Result<(), ObservationApiError> {
334 if self.high_watermark.incarnation_id != self.incarnation_id
335 || self.high_watermark.sequence < self.requested_after
336 {
337 return Err(ObservationApiError::InvalidHighWatermark);
338 }
339 validate_count("resync records", self.records.len(), RESYNC_RECORDS_MAX)?;
340 validate_count("resync gaps", self.gaps.len(), RESYNC_GAPS_MAX)?;
341 validate_count("resync events", self.events.len(), RESYNC_EVENTS_MAX)?;
342
343 let maximum_floor = self
344 .high_watermark
345 .sequence
346 .checked_add(1)
347 .unwrap_or(u64::MAX);
348 let requested_next = self.requested_after.checked_add(1).unwrap_or(u64::MAX);
349 if self.oldest_available_sequence == 0
350 || self.oldest_available_sequence > maximum_floor
351 {
352 return Err(ObservationApiError::InvalidReplayFloor);
353 }
354 let expected_gap = (requested_next < self.oldest_available_sequence).then(|| {
355 ObservationGap {
356 first_sequence: requested_next,
357 last_sequence: self.oldest_available_sequence - 1,
358 }
359 });
360 if self.gaps.as_slice() != expected_gap.as_slice() {
361 return Err(ObservationApiError::InvalidReplayGapProof);
362 }
363
364 for record in &self.records {
365 record.validate_route(&self.node_id, self.incarnation_id)?;
366 }
367 for gap in &self.gaps {
368 gap.validate(self.requested_after, self.high_watermark.sequence)?;
369 }
370 for event in &self.events {
371 event.validate()?;
372 if event.node_id != self.node_id
373 || event.cursor.incarnation_id != self.incarnation_id
374 || event.cursor.sequence <= self.requested_after
375 || event.cursor.sequence < self.oldest_available_sequence
376 || event.cursor.sequence > self.high_watermark.sequence
377 {
378 return Err(ObservationApiError::EventOutsideResyncRoute);
379 }
380 }
381 Ok(())
382 }
383}
384
385#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
386#[serde(rename_all = "kebab-case")]
387pub enum ProjectionAvailability {
388 Unknown,
389 NotObserved,
390 Current,
391 Partial,
392 Frozen,
393}
394
395#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
396#[serde(rename_all = "kebab-case")]
397pub enum ProjectionFreshness {
398 Live,
399 LastKnown,
400 Stale,
401 IncompleteAfterGap,
402 ReplacedIncarnation,
403 Unavailable,
404}
405
406#[derive(Clone, Debug, Eq, Error, PartialEq)]
407pub enum ObservationApiError {
408 #[error("agent instance ID must be non-zero")]
409 ZeroAgentInstanceId,
410 #[error("node event sequence must be non-zero")]
411 ZeroEventSequence,
412 #[error("received_at_ms must be non-zero")]
413 ZeroReceivedAt,
414 #[error("observation route node does not match its envelope")]
415 RouteNodeMismatch,
416 #[error("observation route incarnation does not match its cursor")]
417 RouteIncarnationMismatch,
418 #[error("observation failed protocol validation: {0}")]
419 Observation(#[source] hatchery_observation_protocol::ObservationValidationError),
420 #[error("resync-required oldest cursor is invalid")]
421 InvalidOldestCursor,
422 #[error("resync high watermark is invalid")]
423 InvalidHighWatermark,
424 #[error("resync oldest available sequence is invalid")]
425 InvalidReplayFloor,
426 #[error("resync gaps do not exactly prove the authoritative replay floor")]
427 InvalidReplayGapProof,
428 #[error("resync gap is outside the requested cursor range")]
429 InvalidGap,
430 #[error("resync event is outside the batch route or cursor range")]
431 EventOutsideResyncRoute,
432 #[error("error detail must be a lowercase categorical slug")]
433 InvalidErrorDetail,
434 #[error("{field} count {actual} exceeds maximum {max}")]
435 TooMany {
436 field: &'static str,
437 max: usize,
438 actual: usize,
439 },
440}
441
442fn validate_error_category(detail: &str) -> Result<(), ObservationApiError> {
443 let mut segments = detail.split('-');
444 if segments.any(|segment| {
445 segment.is_empty()
446 || !segment
447 .bytes()
448 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
449 })
450 {
451 return Err(ObservationApiError::InvalidErrorDetail);
452 }
453 Ok(())
454}
455
456fn validate_count(
457 field: &'static str,
458 actual: usize,
459 max: usize,
460) -> Result<(), ObservationApiError> {
461 if actual > max {
462 return Err(ObservationApiError::TooMany { field, max, actual });
463 }
464 Ok(())
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use hatchery_observation_protocol::{
471 ObservationEvidenceV1, ObservationKindV1,
472 };
473
474 fn node(value: &str) -> NodeId {
475 NodeId::new(value).expect("node ID")
476 }
477
478 fn incarnation(byte: u8) -> NodeIncarnationId {
479 NodeIncarnationId::from_bytes([byte; 16])
480 }
481
482 fn runtime(node_id: NodeId, incarnation_id: NodeIncarnationId) -> RuntimeSessionKey {
483 RuntimeSessionKey {
484 node_id,
485 incarnation_id,
486 workspace_id: WorkspaceId::new("workspace-a").expect("workspace ID"),
487 instance_id: AgentInstanceId(1),
488 generation: SessionGeneration(0),
489 }
490 }
491
492 fn event(node_id: NodeId, incarnation_id: NodeIncarnationId) -> ObservationIngressEnvelope {
493 ObservationIngressEnvelope {
494 node_id: node_id.clone(),
495 cursor: NodeCursor {
496 incarnation_id,
497 sequence: 1,
498 },
499 received_at_ms: 10,
500 transport: ObservationTransport::DirectNode,
501 payload: ObservationIngressPayload::Observations {
502 address: ObservationTarget::Runtime {
503 key: runtime(node_id, incarnation_id),
504 },
505 observations: vec![ObservationV1 {
506 source_sequence: 1,
507 observed_at_unix_ms: Some(9),
508 evidence: ObservationEvidenceV1::NodeLifecycle,
509 kind: ObservationKindV1::Ready,
510 truncated: false,
511 }],
512 },
513 }
514 }
515
516 #[test]
517 fn ingress_validation_and_resync_bounds_are_enforced() {
518 let node_a = node("node-a");
519 let node_b = node("node-b");
520 let incarnation_id = incarnation(1);
521 let valid = event(node_a.clone(), incarnation_id);
522 valid.validate().expect("valid ingress");
523
524 let mut wrong_route = valid.clone();
525 wrong_route.node_id = node_b;
526 assert_eq!(
527 wrong_route.validate(),
528 Err(ObservationApiError::RouteNodeMismatch)
529 );
530
531 let mut zero_received = valid.clone();
532 zero_received.received_at_ms = 0;
533 assert_eq!(
534 zero_received.validate(),
535 Err(ObservationApiError::ZeroReceivedAt)
536 );
537
538 let batch = ObservationResyncBatch {
539 node_id: node_a,
540 incarnation_id,
541 requested_after: 0,
542 high_watermark: NodeCursor {
543 incarnation_id,
544 sequence: 1,
545 },
546 oldest_available_sequence: 1,
547 records: Vec::new(),
548 records_complete: true,
549 gaps: vec![ObservationGap {
550 first_sequence: 1,
551 last_sequence: 1,
552 }; RESYNC_GAPS_MAX + 1],
553 events: vec![valid],
554 };
555 assert!(matches!(
556 batch.validate(),
557 Err(ObservationApiError::TooMany {
558 field: "resync gaps",
559 ..
560 })
561 ));
562 }
563
564 #[test]
565 fn managed_route_is_exactly_incarnation_scoped() {
566 let node_id = node("node-a");
567 let first_incarnation = incarnation(1);
568 let second_incarnation = incarnation(2);
569 let key = ManagedSessionKey {
570 node_id: node_id.clone(),
571 incarnation_id: first_incarnation,
572 record_id: SessionRecordId::new("record-inline-a").expect("record ID"),
573 };
574 let target = ObservationTarget::Managed { key: key.clone() };
575 target
576 .validate_route(&node_id, first_incarnation)
577 .expect("exact managed route");
578 assert_eq!(
579 target.validate_route(&node_id, second_incarnation),
580 Err(ObservationApiError::RouteIncarnationMismatch)
581 );
582
583 let link = ManagedRecordLink {
584 managed: key,
585 runtime: None,
586 };
587 link.validate_route(&node_id, first_incarnation)
588 .expect("exact managed link route");
589 assert_eq!(
590 link.validate_route(&node_id, second_incarnation),
591 Err(ObservationApiError::RouteIncarnationMismatch)
592 );
593 }
594}