1use serde::{Deserialize, Deserializer, Serialize};
10use thiserror::Error;
11
12pub const OBSERVATION_EVENT_MAX_BYTES: usize = 4_096;
13pub const OBSERVATION_LABEL_MAX_BYTES: usize = 64;
14pub const OBSERVATION_DETAIL_MAX_BYTES: usize = 1_024;
15pub const OBSERVATION_TODO_ITEMS_MAX: usize = 64;
16pub const OBSERVATION_TODO_TEXT_MAX_BYTES: usize = 256;
17pub const OBSERVATION_PATH_MAX_BYTES: usize = 1_024;
18pub const OBSERVATION_COLLECTION_MAX: usize = 128;
19pub const OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES: usize = 1_024;
25pub const OBSERVATION_ACTION_BLOCKED_HELP_MAX_BYTES: usize = 2_048;
30
31#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum ObservationEvidenceV1 {
34 StructuredProvider,
35 ManagedHook,
36 NodeLifecycle,
37 WorkspaceObservation,
38 HistoryProjection,
39 PtyHint,
40}
41
42#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum ObservationTodoStateV1 {
45 Pending,
46 InProgress,
47 Completed,
48 Unknown,
49}
50
51#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(rename_all = "kebab-case")]
53pub enum ObservationInteractionOutcomeV1 {
54 Approved,
55 Answered,
56 Denied,
57 Interrupted,
58 TurnEnded,
59 Superseded,
60}
61
62#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum HostDecisionAuthorityV1 {
70 Gate,
74 Policy,
78 Operator,
81 DeadlinePolicy,
90}
91
92#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "kebab-case")]
105pub enum BlockAuthorityV1 {
106 HarnessGate,
109 HarnessPolicy,
112 HarnessDeadline,
116 Operator,
120 ProviderClassifier,
123 ProviderPermissionRule,
126 ProviderSandbox,
129 ProviderRefusal,
133 ProviderHook,
136 UserRejected,
140 ProviderQuota,
155 Unknown,
159}
160
161#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
166#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
167pub enum HostRequestDecisionV1 {
168 Granted { by: HostDecisionAuthorityV1 },
170 Denied { by: HostDecisionAuthorityV1 },
172 Deferred,
176}
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
188#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
189pub enum HostRequestOutcomeV1 {
190 Executed,
193 Failed { error: String },
203}
204
205impl Default for HostRequestOutcomeV1 {
206 fn default() -> Self {
212 Self::Executed
213 }
214}
215
216#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
217#[serde(deny_unknown_fields)]
218pub struct ObservationTodoItemV1 {
219 pub id: Option<String>,
220 pub text: String,
221 pub state: ObservationTodoStateV1,
222}
223
224#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
225#[serde(rename_all = "kebab-case")]
226pub enum ObservationSourceFamilyV1 {
227 PtySemantic,
228 Pipe,
229 OneShot,
230 Acp,
231 Hook,
232 ManagedHook,
233 NodeLifecycle,
234 History,
235}
236
237#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
238#[serde(deny_unknown_fields)]
239pub struct ObservationCapabilitiesV1 {
240 pub tools: bool,
241 pub attention: bool,
242 pub subagents: bool,
243 pub todo: bool,
244 pub usage: bool,
245 pub owned_processes: bool,
246 pub file_changes: bool,
247 #[serde(default)]
248 pub history_summary: bool,
249}
250
251#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
252#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
253pub enum ObservationKindV1 {
254 SourceCapabilities {
255 source_family: ObservationSourceFamilyV1,
256 source_adapter: String,
257 capabilities: ObservationCapabilitiesV1,
258 },
259 SessionStarted,
260 Ready,
261 Stopped,
262 Exited {
263 success: Option<bool>,
264 },
265 TurnStarted,
266 Working,
267 TurnCompleted,
268 TurnInterrupted,
269 ToolStarted {
270 correlation_id: String,
271 class: String,
272 },
273 ToolCompleted {
274 correlation_id: String,
275 class: String,
276 success: bool,
277 duration_ms: Option<u64>,
278 },
279 ApprovalRequested {
280 correlation_id: String,
281 tool_class: String,
282 },
283 QuestionRequested {
284 correlation_id: String,
285 tool_class: String,
286 },
287 ApprovalResolved {
288 correlation_id: String,
289 outcome: ObservationInteractionOutcomeV1,
290 },
291 QuestionResolved {
292 correlation_id: String,
293 outcome: ObservationInteractionOutcomeV1,
294 },
295 InteractionResolved {
296 correlation_id: String,
297 outcome: ObservationInteractionOutcomeV1,
298 },
299 SubagentStarted {
300 correlation_id: String,
301 class: String,
302 },
303 SubagentProgress {
304 correlation_id: String,
305 },
306 SubagentCompleted {
307 correlation_id: String,
308 success: Option<bool>,
309 },
310 TodoSnapshot {
311 revision: u64,
312 items: Vec<ObservationTodoItemV1>,
313 complete: bool,
314 },
315 Usage {
316 input_tokens: u64,
317 output_tokens: u64,
318 cache_read_tokens: u64,
319 cache_write_tokens: u64,
320 reasoning_tokens: u64,
321 context_window: Option<u64>,
322 is_cumulative: bool,
323 },
324 ContextWindowUsage {
325 uncached_input_tokens: Option<u64>,
332 cache_read_tokens: Option<u64>,
333 cache_write_tokens: Option<u64>,
334 output_tokens: Option<u64>,
335 unattributed_tokens: Option<u64>,
336 used_tokens: u64,
337 capacity_tokens: u64,
338 },
339 RateLimited,
340 HostRequestObserved {
352 class: String,
353 decision: HostRequestDecisionV1,
354 #[serde(default)]
364 outcome: HostRequestOutcomeV1,
365 },
366 ActionBlocked {
386 correlation_id: Option<String>,
387 tool_class: String,
388 authority: BlockAuthorityV1,
389 reason_kind: Option<String>,
390 reason: String,
391 help: Option<String>,
392 },
393 UnrecognizedNotification {
399 method: String,
400 },
401 OwnedProcessStarted {
402 correlation_id: String,
403 class: String,
404 },
405 OwnedProcessExited {
406 correlation_id: String,
407 success: Option<bool>,
408 exit_code: Option<i32>,
409 },
410 FileChanged {
411 path: Option<String>,
412 },
413 HistorySnapshot {
414 message_count: u64,
415 message_count_exact: bool,
416 completed_turn_count: Option<u64>,
417 total_tokens: Option<u64>,
418 },
419 Gap {
420 missed: u64,
421 },
422 SourceReset,
423 Stale,
424 Error {
425 detail: String,
426 },
427}
428
429#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
430#[serde(deny_unknown_fields)]
431pub struct ObservationV1 {
432 pub source_sequence: u64,
433 pub observed_at_unix_ms: Option<u64>,
434 pub evidence: ObservationEvidenceV1,
435 pub kind: ObservationKindV1,
436 pub truncated: bool,
437}
438
439#[derive(Deserialize)]
440#[serde(deny_unknown_fields)]
441struct ObservationWireV1 {
442 source_sequence: u64,
443 observed_at_unix_ms: Option<u64>,
444 evidence: ObservationEvidenceV1,
445 kind: ObservationKindV1,
446 truncated: bool,
447}
448
449impl<'de> Deserialize<'de> for ObservationV1 {
450 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
451 where
452 D: Deserializer<'de>,
453 {
454 let wire = ObservationWireV1::deserialize(deserializer)?;
455 let observation = Self {
456 source_sequence: wire.source_sequence,
457 observed_at_unix_ms: wire.observed_at_unix_ms,
458 evidence: wire.evidence,
459 kind: wire.kind,
460 truncated: wire.truncated,
461 };
462 observation.validate().map_err(serde::de::Error::custom)?;
463 Ok(observation)
464 }
465}
466
467impl ObservationV1 {
468 pub fn validate(&self) -> Result<(), ObservationValidationError> {
469 if self.source_sequence == 0 {
470 return Err(ObservationValidationError::ZeroSequence);
471 }
472 if self.observed_at_unix_ms == Some(0) {
473 return Err(ObservationValidationError::ZeroObservedAt);
474 }
475 self.kind.validate()?;
476 if matches!(self.kind, ObservationKindV1::ContextWindowUsage { .. })
477 && self.evidence != ObservationEvidenceV1::StructuredProvider
478 {
479 return Err(
480 ObservationValidationError::ContextWindowUsageRequiresStructuredProvider,
481 );
482 }
483 if let ObservationKindV1::ActionBlocked { authority, .. } = &self.kind {
484 if self.evidence == ObservationEvidenceV1::PtyHint
485 && *authority != BlockAuthorityV1::Unknown
486 {
487 return Err(
488 ObservationValidationError::PtyHintActionBlockedRequiresUnknownAuthority,
489 );
490 }
491 }
492 if self.evidence == ObservationEvidenceV1::PtyHint
493 && self.kind.requires_authoritative_semantic_evidence()
494 {
495 return Err(ObservationValidationError::PtyHintClaimsAuthoritativeFact);
496 }
497 if matches!(self.kind, ObservationKindV1::HistorySnapshot { .. })
498 && self.evidence != ObservationEvidenceV1::HistoryProjection
499 {
500 return Err(ObservationValidationError::HistorySnapshotRequiresHistoryProjection);
501 }
502 let actual = self.json_encoded_len();
503 if actual > OBSERVATION_EVENT_MAX_BYTES {
504 return Err(ObservationValidationError::EventTooLarge {
505 max: OBSERVATION_EVENT_MAX_BYTES,
506 actual,
507 });
508 }
509 Ok(())
510 }
511
512 fn json_encoded_len(&self) -> usize {
513 "{\"source_sequence\":".len()
514 + decimal_len(self.source_sequence)
515 + ",\"observed_at_unix_ms\":".len()
516 + option_u64_json_len(self.observed_at_unix_ms)
517 + ",\"evidence\":".len()
518 + json_string_len(self.evidence.wire_name())
519 + ",\"kind\":".len()
520 + self.kind.json_encoded_len()
521 + ",\"truncated\":".len()
522 + bool_json_len(self.truncated)
523 + "}".len()
524 }
525}
526
527impl ObservationKindV1 {
528 pub fn validate(&self) -> Result<(), ObservationValidationError> {
529 match self {
530 Self::SourceCapabilities { source_adapter, .. } => validate_required_text(
531 "observation source adapter",
532 source_adapter,
533 OBSERVATION_LABEL_MAX_BYTES,
534 ),
535 Self::ToolStarted {
536 correlation_id,
537 class,
538 }
539 | Self::ToolCompleted {
540 correlation_id,
541 class,
542 ..
543 } => {
544 validate_required_text(
545 "tool correlation id",
546 correlation_id,
547 OBSERVATION_LABEL_MAX_BYTES,
548 )?;
549 validate_required_text("tool class", class, OBSERVATION_LABEL_MAX_BYTES)
550 }
551 Self::ApprovalRequested {
552 correlation_id,
553 tool_class,
554 }
555 | Self::QuestionRequested {
556 correlation_id,
557 tool_class,
558 } => {
559 validate_required_text(
560 "interaction correlation id",
561 correlation_id,
562 OBSERVATION_LABEL_MAX_BYTES,
563 )?;
564 validate_required_text(
565 "interaction tool class",
566 tool_class,
567 OBSERVATION_LABEL_MAX_BYTES,
568 )
569 }
570 Self::ApprovalResolved { correlation_id, .. }
571 | Self::QuestionResolved { correlation_id, .. }
572 | Self::InteractionResolved { correlation_id, .. } => validate_required_text(
573 "interaction correlation id",
574 correlation_id,
575 OBSERVATION_LABEL_MAX_BYTES,
576 ),
577 Self::SubagentStarted {
578 correlation_id,
579 class,
580 } => {
581 validate_required_text(
582 "subagent correlation id",
583 correlation_id,
584 OBSERVATION_LABEL_MAX_BYTES,
585 )?;
586 validate_required_text("subagent class", class, OBSERVATION_LABEL_MAX_BYTES)
587 }
588 Self::SubagentProgress { correlation_id }
589 | Self::SubagentCompleted { correlation_id, .. } => validate_required_text(
590 "subagent correlation id",
591 correlation_id,
592 OBSERVATION_LABEL_MAX_BYTES,
593 ),
594 Self::OwnedProcessStarted {
595 correlation_id,
596 class,
597 } => {
598 validate_required_text(
599 "owned process correlation id",
600 correlation_id,
601 OBSERVATION_LABEL_MAX_BYTES,
602 )?;
603 validate_required_text("owned process class", class, OBSERVATION_LABEL_MAX_BYTES)
604 }
605 Self::OwnedProcessExited { correlation_id, .. } => validate_required_text(
606 "owned process correlation id",
607 correlation_id,
608 OBSERVATION_LABEL_MAX_BYTES,
609 ),
610 Self::TodoSnapshot {
611 revision, items, ..
612 } => {
613 if *revision == 0 {
614 return Err(ObservationValidationError::ZeroTodoRevision);
615 }
616 if items.len() > OBSERVATION_TODO_ITEMS_MAX {
617 return Err(ObservationValidationError::TooMany {
618 field: "todo items",
619 max: OBSERVATION_TODO_ITEMS_MAX,
620 actual: items.len(),
621 });
622 }
623 for item in items {
624 item.validate()?;
625 }
626 Ok(())
627 }
628 Self::ContextWindowUsage {
629 uncached_input_tokens,
630 cache_read_tokens,
631 cache_write_tokens,
632 output_tokens,
633 unattributed_tokens,
634 used_tokens,
635 capacity_tokens,
636 } => validate_context_window_usage(
637 *uncached_input_tokens,
638 *cache_read_tokens,
639 *cache_write_tokens,
640 *output_tokens,
641 *unattributed_tokens,
642 *used_tokens,
643 *capacity_tokens,
644 ),
645 Self::FileChanged { path: Some(path) } => validate_relative_path(path),
646 Self::Gap { missed: 0 } => Err(ObservationValidationError::ZeroGap),
647 Self::Error { detail } => {
648 validate_required_text("error detail", detail, OBSERVATION_DETAIL_MAX_BYTES)
649 }
650 Self::HostRequestObserved { class, outcome, .. } => {
651 validate_required_text("host request class", class, OBSERVATION_LABEL_MAX_BYTES)?;
652 if let HostRequestOutcomeV1::Failed { error } = outcome {
653 validate_required_text(
654 "host request outcome error",
655 error,
656 OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES,
657 )?;
658 }
659 Ok(())
660 }
661 Self::ActionBlocked {
662 correlation_id,
663 tool_class,
664 reason_kind,
665 reason,
666 help,
667 ..
668 } => {
669 if let Some(correlation_id) = correlation_id {
670 validate_required_text(
671 "action blocked correlation id",
672 correlation_id,
673 OBSERVATION_LABEL_MAX_BYTES,
674 )?;
675 }
676 validate_required_text(
677 "action blocked tool class",
678 tool_class,
679 OBSERVATION_LABEL_MAX_BYTES,
680 )?;
681 if let Some(reason_kind) = reason_kind {
682 validate_required_text(
683 "action blocked reason kind",
684 reason_kind,
685 OBSERVATION_LABEL_MAX_BYTES,
686 )?;
687 }
688 validate_required_text(
689 "action blocked reason",
690 reason,
691 OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES,
692 )?;
693 if let Some(help) = help {
694 validate_required_text(
695 "action blocked help",
696 help,
697 OBSERVATION_ACTION_BLOCKED_HELP_MAX_BYTES,
698 )?;
699 }
700 Ok(())
701 }
702 Self::UnrecognizedNotification { method } => validate_required_text(
703 "unrecognized notification method",
704 method,
705 OBSERVATION_LABEL_MAX_BYTES,
706 ),
707 _ => Ok(()),
708 }
709 }
710
711 fn requires_authoritative_semantic_evidence(&self) -> bool {
712 matches!(
713 self,
714 Self::TurnCompleted
715 | Self::ToolCompleted { .. }
716 | Self::SubagentCompleted { .. }
717 | Self::TodoSnapshot { .. }
718 | Self::FileChanged { .. }
719 | Self::HistorySnapshot { .. }
720 | Self::ContextWindowUsage { .. }
721 )
722 }
723
724 pub fn requires_workflow_detail_capability(&self) -> bool {
725 matches!(
726 self,
727 Self::TodoSnapshot { .. }
728 | Self::FileChanged { .. }
729 | Self::Error { .. }
730 )
731 }
732
733 fn json_encoded_len(&self) -> usize {
734 let kind = self.wire_name();
735 let mut len = "{\"kind\":".len() + json_string_len(kind);
736 match self {
737 Self::SourceCapabilities {
738 source_family,
739 source_adapter,
740 capabilities,
741 } => {
742 len += ",\"source_family\":".len() + json_string_len(source_family.wire_name());
743 len += ",\"source_adapter\":".len() + json_string_len(source_adapter);
744 len += ",\"capabilities\":".len() + capabilities.json_encoded_len();
745 }
746 Self::Exited { success } => {
747 len += ",\"success\":".len() + option_bool_json_len(*success);
748 }
749 Self::ToolStarted {
750 correlation_id,
751 class,
752 } => {
753 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
754 len += ",\"class\":".len() + json_string_len(class);
755 }
756 Self::ToolCompleted {
757 correlation_id,
758 class,
759 success,
760 duration_ms,
761 } => {
762 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
763 len += ",\"class\":".len() + json_string_len(class);
764 len += ",\"success\":".len() + bool_json_len(*success);
765 len += ",\"duration_ms\":".len() + option_u64_json_len(*duration_ms);
766 }
767 Self::ApprovalRequested {
768 correlation_id,
769 tool_class,
770 }
771 | Self::QuestionRequested {
772 correlation_id,
773 tool_class,
774 } => {
775 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
776 len += ",\"tool_class\":".len() + json_string_len(tool_class);
777 }
778 Self::ApprovalResolved {
779 correlation_id,
780 outcome,
781 }
782 | Self::QuestionResolved {
783 correlation_id,
784 outcome,
785 }
786 | Self::InteractionResolved {
787 correlation_id,
788 outcome,
789 } => {
790 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
791 len += ",\"outcome\":".len() + json_string_len(outcome.wire_name());
792 }
793 Self::SubagentStarted {
794 correlation_id,
795 class,
796 } => {
797 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
798 len += ",\"class\":".len() + json_string_len(class);
799 }
800 Self::SubagentProgress { correlation_id } => {
801 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
802 }
803 Self::SubagentCompleted {
804 correlation_id,
805 success,
806 } => {
807 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
808 len += ",\"success\":".len() + option_bool_json_len(*success);
809 }
810 Self::OwnedProcessStarted {
811 correlation_id,
812 class,
813 } => {
814 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
815 len += ",\"class\":".len() + json_string_len(class);
816 }
817 Self::OwnedProcessExited {
818 correlation_id,
819 success,
820 exit_code,
821 } => {
822 len += ",\"correlation_id\":".len() + json_string_len(correlation_id);
823 len += ",\"success\":".len() + option_bool_json_len(*success);
824 len += ",\"exit_code\":".len() + option_i32_json_len(*exit_code);
825 }
826 Self::TodoSnapshot {
827 revision,
828 items,
829 complete,
830 } => {
831 len += ",\"revision\":".len() + decimal_len(*revision);
832 len += ",\"items\":[".len();
833 len += items
834 .iter()
835 .map(ObservationTodoItemV1::json_encoded_len)
836 .sum::<usize>();
837 len += items.len().saturating_sub(1);
838 len += "]".len();
839 len += ",\"complete\":".len() + bool_json_len(*complete);
840 }
841 Self::Usage {
842 input_tokens,
843 output_tokens,
844 cache_read_tokens,
845 cache_write_tokens,
846 reasoning_tokens,
847 context_window,
848 is_cumulative,
849 } => {
850 len += ",\"input_tokens\":".len() + decimal_len(*input_tokens);
851 len += ",\"output_tokens\":".len() + decimal_len(*output_tokens);
852 len += ",\"cache_read_tokens\":".len() + decimal_len(*cache_read_tokens);
853 len += ",\"cache_write_tokens\":".len() + decimal_len(*cache_write_tokens);
854 len += ",\"reasoning_tokens\":".len() + decimal_len(*reasoning_tokens);
855 len += ",\"context_window\":".len() + option_u64_json_len(*context_window);
856 len += ",\"is_cumulative\":".len() + bool_json_len(*is_cumulative);
857 }
858 Self::ContextWindowUsage {
859 uncached_input_tokens,
860 cache_read_tokens,
861 cache_write_tokens,
862 output_tokens,
863 unattributed_tokens,
864 used_tokens,
865 capacity_tokens,
866 } => {
867 len +=
868 ",\"uncached_input_tokens\":".len() + option_u64_json_len(*uncached_input_tokens);
869 len += ",\"cache_read_tokens\":".len() + option_u64_json_len(*cache_read_tokens);
870 len += ",\"cache_write_tokens\":".len() + option_u64_json_len(*cache_write_tokens);
871 len += ",\"output_tokens\":".len() + option_u64_json_len(*output_tokens);
872 len += ",\"unattributed_tokens\":".len() + option_u64_json_len(*unattributed_tokens);
873 len += ",\"used_tokens\":".len() + decimal_len(*used_tokens);
874 len += ",\"capacity_tokens\":".len() + decimal_len(*capacity_tokens);
875 }
876 Self::FileChanged { path } => {
877 len += ",\"path\":".len() + option_string_json_len(path.as_deref());
878 }
879 Self::HistorySnapshot {
880 message_count,
881 message_count_exact,
882 completed_turn_count,
883 total_tokens,
884 } => {
885 len += ",\"message_count\":".len() + decimal_len(*message_count);
886 len += ",\"message_count_exact\":".len() + bool_json_len(*message_count_exact);
887 len += ",\"completed_turn_count\":".len()
888 + option_u64_json_len(*completed_turn_count);
889 len += ",\"total_tokens\":".len() + option_u64_json_len(*total_tokens);
890 }
891 Self::Gap { missed } => {
892 len += ",\"missed\":".len() + decimal_len(*missed);
893 }
894 Self::Error { detail } => {
895 len += ",\"detail\":".len() + json_string_len(detail);
896 }
897 Self::HostRequestObserved { class, decision, outcome } => {
898 len += ",\"class\":".len() + json_string_len(class);
899 len += ",\"decision\":".len() + decision.json_encoded_len();
900 len += ",\"outcome\":".len() + outcome.json_encoded_len();
901 }
902 Self::ActionBlocked {
903 correlation_id,
904 tool_class,
905 authority,
906 reason_kind,
907 reason,
908 help,
909 } => {
910 len += ",\"correlation_id\":".len() + option_string_json_len(correlation_id.as_deref());
911 len += ",\"tool_class\":".len() + json_string_len(tool_class);
912 len += ",\"authority\":".len() + json_string_len(authority.wire_name());
913 len += ",\"reason_kind\":".len() + option_string_json_len(reason_kind.as_deref());
914 len += ",\"reason\":".len() + json_string_len(reason);
915 len += ",\"help\":".len() + option_string_json_len(help.as_deref());
916 }
917 Self::UnrecognizedNotification { method } => {
918 len += ",\"method\":".len() + json_string_len(method);
919 }
920 _ => {}
921 }
922 len + "}".len()
923 }
924
925 fn wire_name(&self) -> &'static str {
926 match self {
927 Self::SourceCapabilities { .. } => "source-capabilities",
928 Self::SessionStarted => "session-started",
929 Self::Ready => "ready",
930 Self::Stopped => "stopped",
931 Self::Exited { .. } => "exited",
932 Self::TurnStarted => "turn-started",
933 Self::Working => "working",
934 Self::TurnCompleted => "turn-completed",
935 Self::TurnInterrupted => "turn-interrupted",
936 Self::ToolStarted { .. } => "tool-started",
937 Self::ToolCompleted { .. } => "tool-completed",
938 Self::ApprovalRequested { .. } => "approval-requested",
939 Self::QuestionRequested { .. } => "question-requested",
940 Self::ApprovalResolved { .. } => "approval-resolved",
941 Self::QuestionResolved { .. } => "question-resolved",
942 Self::InteractionResolved { .. } => "interaction-resolved",
943 Self::SubagentStarted { .. } => "subagent-started",
944 Self::SubagentProgress { .. } => "subagent-progress",
945 Self::SubagentCompleted { .. } => "subagent-completed",
946 Self::TodoSnapshot { .. } => "todo-snapshot",
947 Self::Usage { .. } => "usage",
948 Self::ContextWindowUsage { .. } => "context-window-usage",
949 Self::RateLimited => "rate-limited",
950 Self::HostRequestObserved { .. } => "host-request-observed",
951 Self::ActionBlocked { .. } => "action-blocked",
952 Self::UnrecognizedNotification { .. } => "unrecognized-notification",
953 Self::OwnedProcessStarted { .. } => "owned-process-started",
954 Self::OwnedProcessExited { .. } => "owned-process-exited",
955 Self::FileChanged { .. } => "file-changed",
956 Self::HistorySnapshot { .. } => "history-snapshot",
957 Self::Gap { .. } => "gap",
958 Self::SourceReset => "source-reset",
959 Self::Stale => "stale",
960 Self::Error { .. } => "error",
961 }
962 }
963}
964
965impl ObservationSourceFamilyV1 {
966 fn wire_name(self) -> &'static str {
967 match self {
968 Self::PtySemantic => "pty-semantic",
969 Self::Pipe => "pipe",
970 Self::OneShot => "one-shot",
971 Self::Acp => "acp",
972 Self::Hook => "hook",
973 Self::ManagedHook => "managed-hook",
974 Self::NodeLifecycle => "node-lifecycle",
975 Self::History => "history",
976 }
977 }
978}
979
980impl ObservationCapabilitiesV1 {
981 fn json_encoded_len(self) -> usize {
982 "{\"tools\":".len()
983 + bool_json_len(self.tools)
984 + ",\"attention\":".len()
985 + bool_json_len(self.attention)
986 + ",\"subagents\":".len()
987 + bool_json_len(self.subagents)
988 + ",\"todo\":".len()
989 + bool_json_len(self.todo)
990 + ",\"usage\":".len()
991 + bool_json_len(self.usage)
992 + ",\"owned_processes\":".len()
993 + bool_json_len(self.owned_processes)
994 + ",\"file_changes\":".len()
995 + bool_json_len(self.file_changes)
996 + ",\"history_summary\":".len()
997 + bool_json_len(self.history_summary)
998 + "}".len()
999 }
1000}
1001
1002impl ObservationInteractionOutcomeV1 {
1003 fn wire_name(self) -> &'static str {
1004 match self {
1005 Self::Approved => "approved",
1006 Self::Answered => "answered",
1007 Self::Denied => "denied",
1008 Self::Interrupted => "interrupted",
1009 Self::TurnEnded => "turn-ended",
1010 Self::Superseded => "superseded",
1011 }
1012 }
1013}
1014
1015impl HostDecisionAuthorityV1 {
1016 fn wire_name(self) -> &'static str {
1017 match self {
1018 Self::Gate => "gate",
1019 Self::Policy => "policy",
1020 Self::Operator => "operator",
1021 Self::DeadlinePolicy => "deadline-policy",
1022 }
1023 }
1024}
1025
1026impl BlockAuthorityV1 {
1027 fn wire_name(self) -> &'static str {
1028 match self {
1029 Self::HarnessGate => "harness-gate",
1030 Self::HarnessPolicy => "harness-policy",
1031 Self::HarnessDeadline => "harness-deadline",
1032 Self::Operator => "operator",
1033 Self::ProviderClassifier => "provider-classifier",
1034 Self::ProviderPermissionRule => "provider-permission-rule",
1035 Self::ProviderSandbox => "provider-sandbox",
1036 Self::ProviderRefusal => "provider-refusal",
1037 Self::ProviderHook => "provider-hook",
1038 Self::UserRejected => "user-rejected",
1039 Self::ProviderQuota => "provider-quota",
1040 Self::Unknown => "unknown",
1041 }
1042 }
1043}
1044
1045impl HostRequestDecisionV1 {
1046 fn wire_name(&self) -> &'static str {
1047 match self {
1048 Self::Granted { .. } => "granted",
1049 Self::Denied { .. } => "denied",
1050 Self::Deferred => "deferred",
1051 }
1052 }
1053
1054 fn json_encoded_len(&self) -> usize {
1055 let mut len = "{\"kind\":".len() + json_string_len(self.wire_name());
1056 if let Self::Granted { by } | Self::Denied { by } = self {
1057 len += ",\"by\":".len() + json_string_len(by.wire_name());
1058 }
1059 len + "}".len()
1060 }
1061}
1062
1063impl HostRequestOutcomeV1 {
1064 fn wire_name(&self) -> &'static str {
1065 match self {
1066 Self::Executed => "executed",
1067 Self::Failed { .. } => "failed",
1068 }
1069 }
1070
1071 fn json_encoded_len(&self) -> usize {
1072 let mut len = "{\"kind\":".len() + json_string_len(self.wire_name());
1073 if let Self::Failed { error } = self {
1074 len += ",\"error\":".len() + json_string_len(error);
1075 }
1076 len + "}".len()
1077 }
1078}
1079
1080impl ObservationTodoItemV1 {
1081 pub fn validate(&self) -> Result<(), ObservationValidationError> {
1082 if let Some(id) = self.id.as_deref() {
1083 validate_required_text("todo id", id, OBSERVATION_LABEL_MAX_BYTES)?;
1084 }
1085 validate_required_text("todo text", &self.text, OBSERVATION_TODO_TEXT_MAX_BYTES)
1086 }
1087
1088 fn json_encoded_len(&self) -> usize {
1089 "{\"id\":".len()
1090 + option_string_json_len(self.id.as_deref())
1091 + ",\"text\":".len()
1092 + json_string_len(&self.text)
1093 + ",\"state\":".len()
1094 + json_string_len(self.state.wire_name())
1095 + "}".len()
1096 }
1097}
1098
1099impl ObservationEvidenceV1 {
1100 fn wire_name(self) -> &'static str {
1101 match self {
1102 Self::StructuredProvider => "structured-provider",
1103 Self::ManagedHook => "managed-hook",
1104 Self::NodeLifecycle => "node-lifecycle",
1105 Self::WorkspaceObservation => "workspace-observation",
1106 Self::HistoryProjection => "history-projection",
1107 Self::PtyHint => "pty-hint",
1108 }
1109 }
1110}
1111
1112impl ObservationTodoStateV1 {
1113 fn wire_name(self) -> &'static str {
1114 match self {
1115 Self::Pending => "pending",
1116 Self::InProgress => "in-progress",
1117 Self::Completed => "completed",
1118 Self::Unknown => "unknown",
1119 }
1120 }
1121}
1122
1123#[derive(Clone, Debug, Eq, Error, PartialEq)]
1124pub enum ObservationValidationError {
1125 #[error("observation source sequence must be non-zero")]
1126 ZeroSequence,
1127 #[error("observation timestamp must be non-zero when present")]
1128 ZeroObservedAt,
1129 #[error("observation gap must report at least one missed event")]
1130 ZeroGap,
1131 #[error("todo snapshot revision must be non-zero")]
1132 ZeroTodoRevision,
1133 #[error("observation field '{field}' is empty, contains controls, or exceeds {max} bytes")]
1134 InvalidText { field: &'static str, max: usize },
1135 #[error("observation contains too many {field}: {actual}; maximum is {max}")]
1136 TooMany {
1137 field: &'static str,
1138 max: usize,
1139 actual: usize,
1140 },
1141 #[error("observation path must be a safe relative slash-separated path")]
1142 InvalidPath,
1143 #[error("PTY evidence cannot claim authoritative semantic workflow facts")]
1144 PtyHintClaimsAuthoritativeFact,
1145 #[error("PTY-hint action-blocked observations may only assert authority 'unknown'")]
1146 PtyHintActionBlockedRequiresUnknownAuthority,
1147 #[error("history snapshots require history projection evidence")]
1148 HistorySnapshotRequiresHistoryProjection,
1149 #[error("context-window usage requires structured provider evidence")]
1150 ContextWindowUsageRequiresStructuredProvider,
1151 #[error("context-window capacity must be non-zero")]
1152 ZeroContextWindowCapacity,
1153 #[error("context-window token segments overflow u64")]
1154 ContextWindowSegmentsOverflow,
1155 #[error("context-window token segments sum to {segment_sum}, not used_tokens {used_tokens}")]
1156 ContextWindowSegmentsMismatch { segment_sum: u64, used_tokens: u64 },
1157 #[error("serialized observation is {actual} bytes; maximum is {max}")]
1158 EventTooLarge { max: usize, actual: usize },
1159}
1160
1161fn validate_context_window_usage(
1169 uncached_input_tokens: Option<u64>,
1170 cache_read_tokens: Option<u64>,
1171 cache_write_tokens: Option<u64>,
1172 output_tokens: Option<u64>,
1173 unattributed_tokens: Option<u64>,
1174 used_tokens: u64,
1175 capacity_tokens: u64,
1176) -> Result<(), ObservationValidationError> {
1177 if capacity_tokens == 0 {
1178 return Err(ObservationValidationError::ZeroContextWindowCapacity);
1179 }
1180 if let (
1181 Some(uncached_input_tokens),
1182 Some(cache_read_tokens),
1183 Some(cache_write_tokens),
1184 Some(output_tokens),
1185 Some(unattributed_tokens),
1186 ) = (
1187 uncached_input_tokens,
1188 cache_read_tokens,
1189 cache_write_tokens,
1190 output_tokens,
1191 unattributed_tokens,
1192 ) {
1193 let segment_sum = uncached_input_tokens
1194 .checked_add(cache_read_tokens)
1195 .and_then(|sum| sum.checked_add(cache_write_tokens))
1196 .and_then(|sum| sum.checked_add(output_tokens))
1197 .and_then(|sum| sum.checked_add(unattributed_tokens))
1198 .ok_or(ObservationValidationError::ContextWindowSegmentsOverflow)?;
1199 if segment_sum != used_tokens {
1200 return Err(ObservationValidationError::ContextWindowSegmentsMismatch {
1201 segment_sum,
1202 used_tokens,
1203 });
1204 }
1205 }
1206 Ok(())
1207}
1208
1209fn validate_required_text(
1210 field: &'static str,
1211 value: &str,
1212 max: usize,
1213) -> Result<(), ObservationValidationError> {
1214 if value.is_empty() || value.len() > max || value.chars().any(char::is_control) {
1215 return Err(ObservationValidationError::InvalidText { field, max });
1216 }
1217 Ok(())
1218}
1219
1220fn validate_relative_path(path: &str) -> Result<(), ObservationValidationError> {
1221 if path.is_empty()
1222 || path.len() > OBSERVATION_PATH_MAX_BYTES
1223 || path.starts_with('/')
1224 || path.contains('\\')
1225 || path.contains(':')
1226 || path.chars().any(char::is_control)
1227 || path
1228 .split('/')
1229 .any(|component| component.is_empty() || matches!(component, "." | ".."))
1230 {
1231 return Err(ObservationValidationError::InvalidPath);
1232 }
1233 Ok(())
1234}
1235
1236pub fn truncate_observation_text(value: &str, max_bytes: usize) -> (String, bool) {
1248 if value.len() <= max_bytes {
1249 return (value.to_owned(), false);
1250 }
1251 let mut end = max_bytes;
1252 while end > 0 && !value.is_char_boundary(end) {
1253 end -= 1;
1254 }
1255 (value[..end].to_owned(), true)
1256}
1257
1258fn decimal_len(value: u64) -> usize {
1259 if value == 0 {
1260 1
1261 } else {
1262 value.ilog10() as usize + 1
1263 }
1264}
1265
1266fn bool_json_len(value: bool) -> usize {
1267 if value { 4 } else { 5 }
1268}
1269
1270fn option_bool_json_len(value: Option<bool>) -> usize {
1271 value.map(bool_json_len).unwrap_or(4)
1272}
1273
1274fn option_u64_json_len(value: Option<u64>) -> usize {
1275 value.map(decimal_len).unwrap_or(4)
1276}
1277
1278fn option_i32_json_len(value: Option<i32>) -> usize {
1279 value.map(|value| value.to_string().len()).unwrap_or(4)
1280}
1281
1282fn option_string_json_len(value: Option<&str>) -> usize {
1283 value.map(json_string_len).unwrap_or(4)
1284}
1285
1286fn json_string_len(value: &str) -> usize {
1287 2 + value.len()
1288 + value
1289 .bytes()
1290 .filter(|byte| matches!(byte, b'"' | b'\\'))
1291 .count()
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::*;
1297
1298 fn observation(evidence: ObservationEvidenceV1, kind: ObservationKindV1) -> ObservationV1 {
1299 ObservationV1 {
1300 source_sequence: 1,
1301 observed_at_unix_ms: Some(1_786_671_234_567),
1302 evidence,
1303 kind,
1304 truncated: false,
1305 }
1306 }
1307
1308 #[test]
1309 fn observation_v1_is_bounded_private_and_versioned() {
1310 let value = observation(
1311 ObservationEvidenceV1::StructuredProvider,
1312 ObservationKindV1::Error {
1313 detail: "bounded failure".to_string(),
1314 },
1315 );
1316 value.validate().expect("bounded observation");
1317
1318 let encoded = serde_json::to_vec(&value).expect("serialize observation");
1319 assert!(encoded.len() <= OBSERVATION_EVENT_MAX_BYTES);
1320 assert_eq!(value.json_encoded_len(), encoded.len());
1321 let encoded_text = String::from_utf8(encoded.clone()).expect("JSON is UTF-8");
1322 assert!(!encoded_text.contains("prompt"));
1323 assert!(!encoded_text.contains("transcript"));
1324 let decoded: ObservationV1 =
1325 serde_json::from_slice(&encoded).expect("validated deserialize");
1326 assert_eq!(decoded, value);
1327
1328 let invalid = serde_json::json!({
1329 "source_sequence": 0,
1330 "observed_at_unix_ms": 1,
1331 "evidence": "node-lifecycle",
1332 "kind": { "kind": "ready" },
1333 "truncated": false
1334 });
1335 assert!(serde_json::from_value::<ObservationV1>(invalid).is_err());
1336
1337 let process = observation(
1338 ObservationEvidenceV1::NodeLifecycle,
1339 ObservationKindV1::OwnedProcessExited {
1340 correlation_id: "proc-0123456789abcdef".to_owned(),
1341 success: Some(false),
1342 exit_code: Some(-1),
1343 },
1344 );
1345 process.validate().expect("bounded owned process lifecycle");
1346 let encoded = serde_json::to_vec(&process).expect("serialize owned process lifecycle");
1347 assert_eq!(process.json_encoded_len(), encoded.len());
1348
1349 assert!(ObservationKindV1::TodoSnapshot {
1350 revision: 1,
1351 items: Vec::new(),
1352 complete: true,
1353 }
1354 .requires_workflow_detail_capability());
1355 assert!(!ObservationKindV1::Working.requires_workflow_detail_capability());
1356 }
1357
1358 #[test]
1359 fn source_capabilities_are_base_bounded_categorical_metadata() {
1360 let value = observation(
1361 ObservationEvidenceV1::ManagedHook,
1362 ObservationKindV1::SourceCapabilities {
1363 source_family: ObservationSourceFamilyV1::ManagedHook,
1364 source_adapter: "claude-code".to_owned(),
1365 capabilities: ObservationCapabilitiesV1 {
1366 tools: true,
1367 attention: true,
1368 subagents: true,
1369 ..ObservationCapabilitiesV1::default()
1370 },
1371 },
1372 );
1373 value.validate().expect("categorical source capabilities");
1374 assert!(!value.kind.requires_workflow_detail_capability());
1375 let encoded = serde_json::to_vec(&value).expect("serialize source capabilities");
1376 assert_eq!(value.json_encoded_len(), encoded.len());
1377 assert_eq!(serde_json::from_slice::<ObservationV1>(&encoded).unwrap(), value);
1378
1379 let invalid = observation(
1380 ObservationEvidenceV1::StructuredProvider,
1381 ObservationKindV1::SourceCapabilities {
1382 source_family: ObservationSourceFamilyV1::Pipe,
1383 source_adapter: "bad\nsource".to_owned(),
1384 capabilities: ObservationCapabilitiesV1::default(),
1385 },
1386 );
1387 assert!(matches!(
1388 invalid.validate(),
1389 Err(ObservationValidationError::InvalidText {
1390 field: "observation source adapter",
1391 ..
1392 })
1393 ));
1394 }
1395
1396 #[test]
1397 fn context_window_usage_is_exact_bounded_private_and_serde_stable() {
1398 let valid = observation(
1399 ObservationEvidenceV1::StructuredProvider,
1400 ObservationKindV1::ContextWindowUsage {
1401 uncached_input_tokens: Some(70),
1402 cache_read_tokens: Some(20),
1403 cache_write_tokens: Some(0),
1404 output_tokens: Some(10),
1405 unattributed_tokens: Some(5),
1406 used_tokens: 105,
1407 capacity_tokens: 100,
1408 },
1409 );
1410 valid.validate().expect("over-capacity usage remains a truthful fact");
1411 let encoded = serde_json::to_vec(&valid).expect("serialize context usage");
1412 assert_eq!(valid.json_encoded_len(), encoded.len());
1413 assert!(encoded.len() <= OBSERVATION_EVENT_MAX_BYTES);
1414 assert_eq!(serde_json::from_slice::<ObservationV1>(&encoded).unwrap(), valid);
1415 let text = String::from_utf8(encoded).unwrap();
1416 for forbidden in ["prompt", "transcript", "session_id", "provider_id", "tool_input"] {
1417 assert!(!text.contains(forbidden));
1418 }
1419
1420 let invalid = |kind| observation(ObservationEvidenceV1::StructuredProvider, kind);
1421 assert_eq!(
1422 invalid(ObservationKindV1::ContextWindowUsage {
1423 uncached_input_tokens: Some(1),
1424 cache_read_tokens: Some(0),
1425 cache_write_tokens: Some(0),
1426 output_tokens: Some(0),
1427 unattributed_tokens: Some(0),
1428 used_tokens: 1,
1429 capacity_tokens: 0,
1430 })
1431 .validate(),
1432 Err(ObservationValidationError::ZeroContextWindowCapacity)
1433 );
1434 assert_eq!(
1435 invalid(ObservationKindV1::ContextWindowUsage {
1436 uncached_input_tokens: Some(1),
1437 cache_read_tokens: Some(1),
1438 cache_write_tokens: Some(1),
1439 output_tokens: Some(1),
1440 unattributed_tokens: Some(1),
1441 used_tokens: 4,
1442 capacity_tokens: 1,
1443 })
1444 .validate(),
1445 Err(ObservationValidationError::ContextWindowSegmentsMismatch {
1446 segment_sum: 5,
1447 used_tokens: 4,
1448 })
1449 );
1450 assert_eq!(
1451 invalid(ObservationKindV1::ContextWindowUsage {
1452 uncached_input_tokens: Some(u64::MAX),
1453 cache_read_tokens: Some(1),
1454 cache_write_tokens: Some(0),
1455 output_tokens: Some(0),
1456 unattributed_tokens: Some(0),
1457 used_tokens: u64::MAX,
1458 capacity_tokens: 1,
1459 })
1460 .validate(),
1461 Err(ObservationValidationError::ContextWindowSegmentsOverflow)
1462 );
1463 }
1464
1465 #[test]
1466 fn context_window_usage_with_unobserved_breakdown_skips_the_segment_check() {
1467 let unobserved = observation(
1474 ObservationEvidenceV1::StructuredProvider,
1475 ObservationKindV1::ContextWindowUsage {
1476 uncached_input_tokens: None,
1477 cache_read_tokens: None,
1478 cache_write_tokens: None,
1479 output_tokens: None,
1480 unattributed_tokens: None,
1481 used_tokens: 12_345,
1482 capacity_tokens: 200_000,
1483 },
1484 );
1485 unobserved
1486 .validate()
1487 .expect("used/capacity alone is still a truthful fact");
1488 let encoded = serde_json::to_vec(&unobserved).expect("serialize unobserved usage");
1489 assert_eq!(unobserved.json_encoded_len(), encoded.len());
1490 assert_eq!(
1491 serde_json::from_slice::<ObservationV1>(&encoded).unwrap(),
1492 unobserved
1493 );
1494
1495 assert_eq!(
1496 observation(
1497 ObservationEvidenceV1::StructuredProvider,
1498 ObservationKindV1::ContextWindowUsage {
1499 uncached_input_tokens: None,
1500 cache_read_tokens: None,
1501 cache_write_tokens: None,
1502 output_tokens: None,
1503 unattributed_tokens: None,
1504 used_tokens: 1,
1505 capacity_tokens: 0,
1506 },
1507 )
1508 .validate(),
1509 Err(ObservationValidationError::ZeroContextWindowCapacity),
1510 "capacity is still required even with an unobserved breakdown"
1511 );
1512 }
1513
1514 #[test]
1515 fn context_window_usage_requires_structured_provider_for_validate_and_serde() {
1516 let kind = ObservationKindV1::ContextWindowUsage {
1517 uncached_input_tokens: Some(70),
1518 cache_read_tokens: Some(20),
1519 cache_write_tokens: Some(0),
1520 output_tokens: Some(10),
1521 unattributed_tokens: Some(5),
1522 used_tokens: 105,
1523 capacity_tokens: 100,
1524 };
1525 let structured = observation(
1526 ObservationEvidenceV1::StructuredProvider,
1527 kind.clone(),
1528 );
1529 structured.validate().expect("structured provider is authoritative");
1530 let structured_json = serde_json::to_vec(&structured).unwrap();
1531 assert_eq!(
1532 serde_json::from_slice::<ObservationV1>(&structured_json).unwrap(),
1533 structured
1534 );
1535
1536 for evidence in [
1537 ObservationEvidenceV1::ManagedHook,
1538 ObservationEvidenceV1::NodeLifecycle,
1539 ObservationEvidenceV1::WorkspaceObservation,
1540 ObservationEvidenceV1::HistoryProjection,
1541 ObservationEvidenceV1::PtyHint,
1542 ] {
1543 let rejected = observation(evidence, kind.clone());
1544 assert_eq!(
1545 rejected.validate(),
1546 Err(
1547 ObservationValidationError::ContextWindowUsageRequiresStructuredProvider
1548 ),
1549 "{evidence:?} must not claim exact current context"
1550 );
1551 let encoded = serde_json::to_vec(&rejected).unwrap();
1552 let error = serde_json::from_slice::<ObservationV1>(&encoded).unwrap_err();
1553 assert!(
1554 error
1555 .to_string()
1556 .contains("context-window usage requires structured provider evidence"),
1557 "unexpected serde error for {evidence:?}: {error}"
1558 );
1559 }
1560 }
1561
1562 #[test]
1568 fn host_request_observed_carries_a_bounded_failed_outcome() {
1569 let value = observation(
1570 ObservationEvidenceV1::StructuredProvider,
1571 ObservationKindV1::HostRequestObserved {
1572 class: "Terminal".to_owned(),
1573 decision: HostRequestDecisionV1::Granted { by: HostDecisionAuthorityV1::Policy },
1574 outcome: HostRequestOutcomeV1::Failed {
1575 error: "terminal/create spawn failed: os error 3".to_owned(),
1576 },
1577 },
1578 );
1579 value.validate().expect("bounded granted-but-failed host request");
1580 let encoded = serde_json::to_vec(&value).expect("serialize host request observed");
1581 assert_eq!(value.json_encoded_len(), encoded.len());
1582 assert_eq!(serde_json::from_slice::<ObservationV1>(&encoded).unwrap(), value);
1583
1584 let executed = observation(
1585 ObservationEvidenceV1::StructuredProvider,
1586 ObservationKindV1::HostRequestObserved {
1587 class: "Terminal".to_owned(),
1588 decision: HostRequestDecisionV1::Granted { by: HostDecisionAuthorityV1::Policy },
1589 outcome: HostRequestOutcomeV1::Executed,
1590 },
1591 );
1592 executed.validate().expect("bounded granted-and-executed host request");
1593 let encoded_executed = serde_json::to_vec(&executed).expect("serialize executed outcome");
1594 assert_eq!(executed.json_encoded_len(), encoded_executed.len());
1595
1596 let oversized_error = "e".repeat(OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES + 1);
1597 let rejected = observation(
1598 ObservationEvidenceV1::StructuredProvider,
1599 ObservationKindV1::HostRequestObserved {
1600 class: "Terminal".to_owned(),
1601 decision: HostRequestDecisionV1::Granted { by: HostDecisionAuthorityV1::Policy },
1602 outcome: HostRequestOutcomeV1::Failed { error: oversized_error },
1603 },
1604 );
1605 assert_eq!(
1606 rejected.validate(),
1607 Err(ObservationValidationError::InvalidText {
1608 field: "host request outcome error",
1609 max: OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES,
1610 })
1611 );
1612 }
1613
1614 #[test]
1624 fn host_request_observed_outcome_defaults_to_executed_for_pre_k1d_json() {
1625 let pre_k1d = serde_json::json!({
1626 "source_sequence": 1,
1627 "observed_at_unix_ms": 1_786_671_234_567u64,
1628 "evidence": "structured-provider",
1629 "kind": {
1630 "kind": "host-request-observed",
1631 "class": "Terminal",
1632 "decision": { "kind": "granted", "by": "policy" }
1633 },
1634 "truncated": false
1635 });
1636 let decoded: ObservationV1 = serde_json::from_value(pre_k1d)
1637 .expect("pre-K1d shape without outcome must still deserialize");
1638 assert!(matches!(
1639 decoded.kind,
1640 ObservationKindV1::HostRequestObserved {
1641 decision: HostRequestDecisionV1::Granted {
1642 by: HostDecisionAuthorityV1::Policy
1643 },
1644 outcome: HostRequestOutcomeV1::Executed,
1645 ..
1646 }
1647 ));
1648 decoded.validate().expect("defaulted outcome remains a valid observation");
1649 }
1650
1651 #[test]
1652 fn history_snapshot_is_bounded_and_contains_no_content_fields() {
1653 let unknown_tokens = observation(
1654 ObservationEvidenceV1::HistoryProjection,
1655 ObservationKindV1::HistorySnapshot {
1656 message_count: 12,
1657 message_count_exact: true,
1658 completed_turn_count: Some(6),
1659 total_tokens: None,
1660 },
1661 );
1662 unknown_tokens.validate().expect("bounded history snapshot");
1663 let encoded = serde_json::to_vec(&unknown_tokens).expect("serialize history snapshot");
1664 assert_eq!(unknown_tokens.json_encoded_len(), encoded.len());
1665 assert!(encoded.len() <= OBSERVATION_EVENT_MAX_BYTES);
1666
1667 let value = serde_json::from_slice::<serde_json::Value>(&encoded).unwrap();
1668 let history = value["kind"].as_object().unwrap();
1669 let keys = history.keys().map(String::as_str).collect::<Vec<_>>();
1670 assert_eq!(
1671 keys,
1672 vec![
1673 "completed_turn_count",
1674 "kind",
1675 "message_count",
1676 "message_count_exact",
1677 "total_tokens",
1678 ]
1679 );
1680 for forbidden in [
1681 "text",
1682 "prompt",
1683 "transcript",
1684 "path",
1685 "cwd",
1686 "session_id",
1687 "provider_id",
1688 "tool_input",
1689 "tool_output",
1690 ] {
1691 assert!(history.get(forbidden).is_none());
1692 }
1693
1694 let observed_zero = observation(
1695 ObservationEvidenceV1::HistoryProjection,
1696 ObservationKindV1::HistorySnapshot {
1697 message_count: 0,
1698 message_count_exact: false,
1699 completed_turn_count: None,
1700 total_tokens: Some(0),
1701 },
1702 );
1703 observed_zero.validate().expect("observed zero is factual");
1704 assert_ne!(
1705 serde_json::to_value(&unknown_tokens).unwrap()["kind"]["total_tokens"],
1706 serde_json::to_value(&observed_zero).unwrap()["kind"]["total_tokens"]
1707 );
1708
1709 assert_eq!(
1710 observation(
1711 ObservationEvidenceV1::StructuredProvider,
1712 ObservationKindV1::HistorySnapshot {
1713 message_count: 1,
1714 message_count_exact: true,
1715 completed_turn_count: None,
1716 total_tokens: None,
1717 },
1718 )
1719 .validate(),
1720 Err(ObservationValidationError::HistorySnapshotRequiresHistoryProjection)
1721 );
1722 }
1723
1724 #[test]
1742 fn pty_hint_cannot_claim_authoritative_workflow_facts() {
1743 let rejected = [
1744 (
1745 ObservationKindV1::TurnCompleted,
1746 ObservationValidationError::PtyHintClaimsAuthoritativeFact,
1747 ),
1748 (
1749 ObservationKindV1::ToolCompleted {
1750 correlation_id: "tool-0123456789abcdef".to_string(),
1751 class: "command".to_string(),
1752 success: true,
1753 duration_ms: Some(5),
1754 },
1755 ObservationValidationError::PtyHintClaimsAuthoritativeFact,
1756 ),
1757 (
1758 ObservationKindV1::SubagentCompleted {
1759 correlation_id: "child-1".to_string(),
1760 success: Some(true),
1761 },
1762 ObservationValidationError::PtyHintClaimsAuthoritativeFact,
1763 ),
1764 (
1765 ObservationKindV1::TodoSnapshot {
1766 revision: 1,
1767 items: Vec::new(),
1768 complete: true,
1769 },
1770 ObservationValidationError::PtyHintClaimsAuthoritativeFact,
1771 ),
1772 (
1773 ObservationKindV1::FileChanged {
1774 path: Some("src/lib.rs".to_string()),
1775 },
1776 ObservationValidationError::PtyHintClaimsAuthoritativeFact,
1777 ),
1778 (
1779 ObservationKindV1::HistorySnapshot {
1780 message_count: 1,
1781 message_count_exact: true,
1782 completed_turn_count: None,
1783 total_tokens: None,
1784 },
1785 ObservationValidationError::PtyHintClaimsAuthoritativeFact,
1786 ),
1787 (
1788 ObservationKindV1::ContextWindowUsage {
1793 uncached_input_tokens: Some(1),
1794 cache_read_tokens: Some(0),
1795 cache_write_tokens: Some(0),
1796 output_tokens: Some(0),
1797 unattributed_tokens: Some(0),
1798 used_tokens: 1,
1799 capacity_tokens: 1,
1800 },
1801 ObservationValidationError::ContextWindowUsageRequiresStructuredProvider,
1802 ),
1803 ];
1804
1805 for (kind, expected) in rejected {
1806 assert_eq!(
1807 observation(ObservationEvidenceV1::PtyHint, kind).validate(),
1808 Err(expected),
1809 );
1810 }
1811 observation(ObservationEvidenceV1::PtyHint, ObservationKindV1::Working)
1812 .validate()
1813 .expect("activity hint is non-authoritative");
1814 }
1815
1816 #[test]
1817 fn todo_snapshot_rejects_unsafe_or_oversize_content() {
1818 let zero_revision = observation(
1819 ObservationEvidenceV1::ManagedHook,
1820 ObservationKindV1::TodoSnapshot {
1821 revision: 0,
1822 items: Vec::new(),
1823 complete: true,
1824 },
1825 );
1826 assert_eq!(
1827 zero_revision.validate(),
1828 Err(ObservationValidationError::ZeroTodoRevision)
1829 );
1830
1831 let unsafe_text = observation(
1832 ObservationEvidenceV1::StructuredProvider,
1833 ObservationKindV1::TodoSnapshot {
1834 revision: 1,
1835 items: vec![ObservationTodoItemV1 {
1836 id: Some("todo-1".to_string()),
1837 text: "unsafe\u{1b}text".to_string(),
1838 state: ObservationTodoStateV1::Pending,
1839 }],
1840 complete: true,
1841 },
1842 );
1843 assert!(matches!(
1844 unsafe_text.validate(),
1845 Err(ObservationValidationError::InvalidText {
1846 field: "todo text",
1847 ..
1848 })
1849 ));
1850
1851 let too_many = observation(
1852 ObservationEvidenceV1::StructuredProvider,
1853 ObservationKindV1::TodoSnapshot {
1854 revision: 2,
1855 items: (0..=OBSERVATION_TODO_ITEMS_MAX)
1856 .map(|index| ObservationTodoItemV1 {
1857 id: Some(format!("todo-{index}")),
1858 text: "bounded".to_string(),
1859 state: ObservationTodoStateV1::Unknown,
1860 })
1861 .collect(),
1862 complete: true,
1863 },
1864 );
1865 assert!(matches!(
1866 too_many.validate(),
1867 Err(ObservationValidationError::TooMany {
1868 field: "todo items",
1869 ..
1870 })
1871 ));
1872
1873 let oversized_event = observation(
1874 ObservationEvidenceV1::StructuredProvider,
1875 ObservationKindV1::TodoSnapshot {
1876 revision: 3,
1877 items: (0..20)
1878 .map(|index| ObservationTodoItemV1 {
1879 id: Some(format!("todo-{index}")),
1880 text: "x".repeat(OBSERVATION_TODO_TEXT_MAX_BYTES),
1881 state: ObservationTodoStateV1::InProgress,
1882 })
1883 .collect(),
1884 complete: false,
1885 },
1886 );
1887 assert!(matches!(
1888 oversized_event.validate(),
1889 Err(ObservationValidationError::EventTooLarge { .. })
1890 ));
1891
1892 let unsafe_path = observation(
1893 ObservationEvidenceV1::WorkspaceObservation,
1894 ObservationKindV1::FileChanged {
1895 path: Some("src/../secret".to_string()),
1896 },
1897 );
1898 assert_eq!(
1899 unsafe_path.validate(),
1900 Err(ObservationValidationError::InvalidPath)
1901 );
1902 }
1903
1904 fn action_blocked(authority: BlockAuthorityV1, reason: &str) -> ObservationKindV1 {
1905 ObservationKindV1::ActionBlocked {
1906 correlation_id: Some("tool-0123456789abcdef".to_string()),
1907 tool_class: "Bash".to_string(),
1908 authority,
1909 reason_kind: Some("nonExecutionKind".to_string()),
1910 reason: reason.to_string(),
1911 help: Some("add a Bash permission rule".to_string()),
1912 }
1913 }
1914
1915 #[test]
1920 fn action_blocked_structured_provider_accepts_any_authority() {
1921 for authority in [
1922 BlockAuthorityV1::HarnessGate,
1923 BlockAuthorityV1::HarnessPolicy,
1924 BlockAuthorityV1::HarnessDeadline,
1925 BlockAuthorityV1::Operator,
1926 BlockAuthorityV1::ProviderClassifier,
1927 BlockAuthorityV1::ProviderPermissionRule,
1928 BlockAuthorityV1::ProviderSandbox,
1929 BlockAuthorityV1::ProviderRefusal,
1930 BlockAuthorityV1::ProviderHook,
1931 BlockAuthorityV1::UserRejected,
1932 BlockAuthorityV1::ProviderQuota,
1933 BlockAuthorityV1::Unknown,
1934 ] {
1935 let value = observation(
1936 ObservationEvidenceV1::StructuredProvider,
1937 action_blocked(authority, "denied by the Claude Code auto mode classifier"),
1938 );
1939 value
1940 .validate()
1941 .unwrap_or_else(|error| panic!("{authority:?} must be accepted: {error}"));
1942 let encoded = serde_json::to_vec(&value).expect("serialize action-blocked");
1943 assert_eq!(value.json_encoded_len(), encoded.len());
1944 assert!(encoded.len() <= OBSERVATION_EVENT_MAX_BYTES);
1945 assert_eq!(serde_json::from_slice::<ObservationV1>(&encoded).unwrap(), value);
1946 }
1947 }
1948
1949 #[test]
1953 fn action_blocked_pty_hint_requires_unknown_authority() {
1954 let refused = [
1955 BlockAuthorityV1::HarnessGate,
1956 BlockAuthorityV1::HarnessPolicy,
1957 BlockAuthorityV1::HarnessDeadline,
1958 BlockAuthorityV1::Operator,
1959 BlockAuthorityV1::ProviderClassifier,
1960 BlockAuthorityV1::ProviderPermissionRule,
1961 BlockAuthorityV1::ProviderSandbox,
1962 BlockAuthorityV1::ProviderRefusal,
1963 BlockAuthorityV1::ProviderHook,
1964 BlockAuthorityV1::UserRejected,
1965 BlockAuthorityV1::ProviderQuota,
1966 ];
1967 for authority in refused {
1968 let rejected = observation(
1969 ObservationEvidenceV1::PtyHint,
1970 action_blocked(authority, "bash denied by auto mode"),
1971 );
1972 assert_eq!(
1973 rejected.validate(),
1974 Err(ObservationValidationError::PtyHintActionBlockedRequiresUnknownAuthority),
1975 "{authority:?} must be refused under pty-hint evidence"
1976 );
1977 }
1978
1979 let accepted = observation(
1980 ObservationEvidenceV1::PtyHint,
1981 action_blocked(BlockAuthorityV1::Unknown, "bash denied by auto mode"),
1982 );
1983 accepted
1984 .validate()
1985 .expect("pty-hint may assert Unknown authority");
1986 }
1987
1988 #[test]
1995 fn action_blocked_reason_is_bounded_and_truncation_is_producer_side() {
1996 let oversized_reason = "r".repeat(OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES + 1);
1997 let rejected = observation(
1998 ObservationEvidenceV1::StructuredProvider,
1999 action_blocked(BlockAuthorityV1::ProviderClassifier, &oversized_reason),
2000 );
2001 assert!(matches!(
2002 rejected.validate(),
2003 Err(ObservationValidationError::InvalidText {
2004 field: "action blocked reason",
2005 ..
2006 })
2007 ));
2008
2009 let mut boundary_straddling =
2013 "a".repeat(OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES - 1);
2014 boundary_straddling.push('€'); boundary_straddling.push_str(" trailing text past the limit");
2016 let (truncated, was_truncated) = truncate_observation_text(
2017 &boundary_straddling,
2018 OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES,
2019 );
2020 assert!(was_truncated);
2021 assert!(truncated.len() <= OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES);
2022 assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
2023
2024 let fits_after_cut = observation(
2025 ObservationEvidenceV1::StructuredProvider,
2026 action_blocked(BlockAuthorityV1::ProviderClassifier, &truncated),
2027 );
2028 fits_after_cut
2029 .validate()
2030 .expect("truncated reason fits the bound");
2031
2032 let (untouched, was_truncated) =
2033 truncate_observation_text("short reason", OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES);
2034 assert!(!was_truncated);
2035 assert_eq!(untouched, "short reason");
2036 }
2037}