Skip to main content

hatchery_observation_protocol/
lib.rs

1//! Dependency-light, versioned observations for provider-session monitoring.
2//!
3//! Observations carry bounded, allowlisted workflow facts. They never carry
4//! prompts, transcript text, raw tool input/output, credentials, or provider
5//! configuration. Consumers must still treat the evidence source as part of
6//! the fact: terminal hints cannot authoritatively claim semantic workflow
7//! completion.
8
9use 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;
19/// Max bytes for `ObservationKindV1::ActionBlocked::reason` -- the
20/// authority's own refusal sentence (e.g. "blocked by dangerous-command
21/// gate: rule=…, argument=…", or a provider's "Reason: Blocked by
22/// classifier."). Verbatim and bounded, never summarised -- see
23/// [`truncate_observation_text`] for the producer-side cut this bound backs.
24pub const OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES: usize = 1_024;
25/// Max bytes for `ObservationKindV1::ActionBlocked::help` -- the guidance
26/// tail a CLI attaches after naming the block (e.g. "add a Bash permission
27/// rule"). Larger than `reason` because guidance text runs longer than a
28/// one-line refusal.
29pub 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/// WHO decided a host request the agent sent to the ACP host -- see
63/// [`ObservationKindV1::HostRequestObserved`]. Mirrors `gate4agent`'s own
64/// `HostDecisionAuthority` one-for-one; this crate is dependency-light and
65/// keeps its own copy rather than depending on `gate4agent`, the same
66/// convention every other `*V1` wire type here follows.
67#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum HostDecisionAuthorityV1 {
70    /// The dangerous-command gate forced this outcome ahead of the host's
71    /// policy -- `terminal/create` and `execute`-kind `session/request_
72    /// permission` only. Always a denial.
73    Gate,
74    /// The host's policy decided the request the instant it arrived -- the
75    /// default path for every request that is neither gate-blocked nor
76    /// deferred.
77    Policy,
78    /// An operator answered a `session/request_permission` call that had
79    /// been left `HostRequestDecisionV1::Deferred`.
80    Operator,
81    /// A `session/request_permission` call left `HostRequestDecisionV1::
82    /// Deferred` reached its deadline with no operator answer, so the
83    /// host's policy -- the SAME policy that would have answered it
84    /// immediately had deferral never been enabled -- decided it instead.
85    /// Deliberately its own variant rather than `Policy`: folding it in
86    /// would erase the fact that an operator was asked first and nobody
87    /// answered in time. Equally deliberately not `Operator`: no human
88    /// made this choice.
89    DeadlinePolicy,
90}
91
92/// WHO or WHAT blocked an action -- see [`ObservationKindV1::ActionBlocked`].
93/// A strictly wider vocabulary than [`HostDecisionAuthorityV1`]: the harness
94/// itself only ever produces `HarnessGate`/`HarnessPolicy`/`HarnessDeadline`/
95/// `Operator` (see that type's own variants, which this one mirrors
96/// one-for-one for the harness's own four), but a block can also come from
97/// the PROVIDER side of the wire -- a classifier, a permission rule, a
98/// sandbox, a plain refusal, a hook, or the user declining inside the
99/// provider's own UI -- none of which `HostDecisionAuthorityV1` has any
100/// vocabulary for at all. `Unknown` is the evidence-gated fallback: see
101/// `ObservationV1::validate`'s pty-hint rule, the only evidence class this
102/// crate lets claim it.
103#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "kebab-case")]
105pub enum BlockAuthorityV1 {
106    /// The dangerous-command gate -- mirrors [`HostDecisionAuthorityV1::
107    /// Gate`].
108    HarnessGate,
109    /// The harness's own host policy -- mirrors [`HostDecisionAuthorityV1::
110    /// Policy`].
111    HarnessPolicy,
112    /// A deferred request the harness's policy decided after no operator
113    /// answered in time -- mirrors [`HostDecisionAuthorityV1::
114    /// DeadlinePolicy`].
115    HarnessDeadline,
116    /// An operator explicitly declined -- mirrors [`HostDecisionAuthorityV1::
117    /// Operator`]. The one variant this type shares by name, not just by
118    /// meaning, with `HostDecisionAuthorityV1`.
119    Operator,
120    /// The provider's own auto-mode classifier (e.g. Claude Code's auto
121    /// mode).
122    ProviderClassifier,
123    /// A provider-side permission rule (an allow/deny list entry) refused
124    /// the call, distinct from a classifier's runtime judgement.
125    ProviderPermissionRule,
126    /// The provider's sandbox refused the call (a filesystem or network
127    /// boundary the sandbox itself enforces, not a policy decision).
128    ProviderSandbox,
129    /// The provider declined with a `stopReason: "refusal"` or equivalent,
130    /// carrying no more specific typed reason than "the provider itself said
131    /// no".
132    ProviderRefusal,
133    /// A provider-managed hook (e.g. Claude Code's `PermissionDenied` hook)
134    /// reported the block.
135    ProviderHook,
136    /// A human declined inside the provider's OWN UI/CLI (not this
137    /// harness's operator surface) -- the provider's own `user-rejected`-
138    /// shaped outcome.
139    UserRejected,
140    /// The provider's own account/plan quota, rate limit, or usage cap was
141    /// exhausted -- e.g. codex-acp's `session/prompt` RPC error carrying
142    /// `data.codexErrorInfo: "usageLimitExceeded"` (measured live
143    /// 2026-09-05). `reason_kind` on the sibling `ActionBlocked` carries
144    /// the provider's own vendor code for this (`"usageLimitExceeded"`,
145    /// `"rate_limit"`, ...) -- this variant only says WHO/WHAT the
146    /// authority is, never a second copy of the code itself. Distinct from
147    /// `ProviderRefusal`: a quota exhaustion is an account/plan state, not
148    /// the model declining the specific request. A new unit variant on a
149    /// closed, exact-version-negotiated wire enum needs no `#[serde(default)]`
150    /// fallback -- there is no older payload that could ever carry it, and
151    /// `NODE_PROTOCOL_VERSION`'s exact-match negotiation means an older
152    /// binary that does not know this variant never receives one in the
153    /// first place.
154    ProviderQuota,
155    /// No typed field named who blocked it -- text matching may still have
156    /// filled `reason`/`help`, but never this field; see the module doc
157    /// comment's rule that authority is never guessed.
158    Unknown,
159}
160
161/// A typed answer to "what happened to this host request" -- see
162/// [`ObservationKindV1::HostRequestObserved`]. Mirrors `gate4agent`'s own
163/// `HostRequestDecision` one-for-one; see [`HostDecisionAuthorityV1`]'s doc
164/// comment for why this crate keeps its own copy.
165#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
166#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
167pub enum HostRequestDecisionV1 {
168    /// The request was allowed. `by` is who made that call.
169    Granted { by: HostDecisionAuthorityV1 },
170    /// The request was refused. `by` is who made that call.
171    Denied { by: HostDecisionAuthorityV1 },
172    /// The request has arrived and been recorded, but nothing has decided
173    /// it yet. A later `ObservationKindV1::HostRequestObserved` reports the
174    /// eventual `Granted`/`Denied` outcome once one exists.
175    Deferred,
176}
177
178/// Whether a `Granted` host request's underlying operation actually ran
179/// without an I/O or execution problem -- see [`ObservationKindV1::
180/// HostRequestObserved`]. Mirrors `gate4agent`'s own `HostRequestOutcome`
181/// one-for-one; see [`HostDecisionAuthorityV1`]'s doc comment for why this
182/// crate keeps its own copy rather than importing it.
183///
184/// Only meaningful paired with `HostRequestDecisionV1::Granted`: a `Denied`
185/// or `Deferred` request never attempted its underlying operation, so it is
186/// always `Executed` there for lack of anything to have failed running.
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
188#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
189pub enum HostRequestOutcomeV1 {
190    /// No execution problem -- either the request ran cleanly, or (for
191    /// `Denied`/`Deferred`) no execution was ever attempted to fail.
192    Executed,
193    /// The request was authorized but failed while running it. `error` is
194    /// the bounded underlying I/O/execution failure text (e.g. an OS error
195    /// from a spawn call) -- never a policy/gate refusal message, which
196    /// stays on `ObservationKindV1::HostRequestObserved`'s own audit trail
197    /// via the sibling `ActionBlocked` observation instead. This is NEVER a
198    /// `Denied` decision in disguise: a gate/policy/deadline refusal is
199    /// `HostRequestDecisionV1::Denied`, never `Granted` paired with
200    /// `Failed` -- the two answer different questions ("was this allowed"
201    /// versus "did doing it succeed") and must never be folded into one.
202    Failed { error: String },
203}
204
205impl Default for HostRequestOutcomeV1 {
206    /// Backs `ObservationKindV1::HostRequestObserved::outcome`'s
207    /// `#[serde(default)]` -- a durable record written before this field
208    /// existed (pre-K1d) never recorded an execution failure at all, so an
209    /// observed `HostRequestObserved` from that era always meant the
210    /// request ran; see that field's own doc comment.
211    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        /// Cache/segment breakdown of `used_tokens`. `None` when the
326        /// source only reported `used_tokens`/`capacity_tokens` and never
327        /// observed how the total decomposes -- see `ProviderEvent::
328        /// UsageUpdated`. Never defaulted to zero: a zero would assert a
329        /// segment was observed to be empty when it was never observed at
330        /// all.
331        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    /// The agent asked the ACP host for something (`session/request_
341    /// permission`, `fs/read_text_file`, `fs/write_text_file`, `terminal/
342    /// create`, `terminal/output`, `terminal/wait_for_exit`, `terminal/
343    /// kill`, `terminal/release`). `class` is a coarse bucket of the ACP
344    /// method requested -- the same scale as `ToolStarted::class` -- never
345    /// the raw request parameters: what path was read or what command ran
346    /// is never carried on this wire. `decision` is `HostRequestDecisionV1::
347    /// Deferred` when the request has arrived but nothing has decided it
348    /// yet (only reachable for `session/request_permission`); a later
349    /// observation for the same underlying request reports the eventual
350    /// `Granted`/`Denied` outcome once one exists.
351    HostRequestObserved {
352        class: String,
353        decision: HostRequestDecisionV1,
354        /// Whether an authorized (`Granted`) request actually ran cleanly
355        /// or failed doing so -- see [`HostRequestOutcomeV1`]'s own doc
356        /// comment for why this is a separate field from `decision` rather
357        /// than a third flavor of `Denied`. Always `Executed` for `Denied`/
358        /// `Deferred` (nothing ran to fail). `#[serde(default)]` reads a
359        /// durable record written before this field existed (K1d) as
360        /// `Executed` -- honest, since before this field existed an
361        /// observed `HostRequestObserved` implied a `Granted` request had
362        /// run, and there was nothing else it could have meant.
363        #[serde(default)]
364        outcome: HostRequestOutcomeV1,
365    },
366    /// Something the agent tried was blocked mid-turn -- by this harness's
367    /// own gate/policy/deadline, by an operator, or by the provider itself
368    /// (a classifier, a permission rule, a sandbox, a plain refusal, a hook,
369    /// or the provider's own user-rejected outcome). `correlation_id` is the
370    /// tool call this block belongs to, when there is one, using the SAME
371    /// opaque id scheme `ToolStarted::correlation_id` does -- `None` when the
372    /// block has no tool call to correlate against yet (an operator/policy
373    /// denial today never does; a provider-side block wired up later may).
374    /// `tool_class` is the same coarse bucket `ToolStarted::class`/
375    /// `HostRequestObserved::class` use, never the raw command. `authority`
376    /// is read off a typed field or this harness's own decision, NEVER
377    /// guessed from text -- see [`BlockAuthorityV1`]'s doc comment.
378    /// `reason_kind` is the source's own machine code for why (ACP's
379    /// `nonExecutionKind`, a provider's `decision_reason_type`, a
380    /// `stopReason`), when one exists. `reason` and `help` are bounded,
381    /// verbatim free text -- never summarised or rewritten -- and are a
382    /// SEPARATE contract from `Error::detail`, which stays a categorical
383    /// slug; see this crate's module doc comment for why a sentence must
384    /// never travel through that field.
385    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    /// A JSON-RPC notification the reader received but could not classify
394    /// into any other kind here -- the protocol said something this build
395    /// does not parse. `method` is the bare JSON-RPC method string (a
396    /// small, protocol-defined vocabulary), never the notification's own
397    /// payload.
398    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
1161/// The five breakdown segments are `Option<u64>` -- a source that only
1162/// observed `used_tokens`/`capacity_tokens` (see `ObservationKindV1::
1163/// ContextWindowUsage`'s own doc) reports them as `None`. The segment-sum
1164/// check below only runs when EVERY segment was observed: a partially
1165/// unknown breakdown has nothing sound to compare against `used_tokens`,
1166/// and treating a missing segment as zero would assert it was observed
1167/// to be empty rather than never measured.
1168fn 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
1236/// Cuts `value` to at most `max_bytes`, backing off to the nearest UTF-8
1237/// character boundary at or before that limit -- never inside a multi-byte
1238/// sequence. Returns `(text, true)` when a cut was made, `(text, false)`
1239/// when `value` already fit. This is the one honest way to bound
1240/// `ActionBlocked::reason`/`help` without rewriting them: the byte limit is
1241/// enforced here, at the producer, BEFORE `ObservationV1::validate` ever
1242/// sees the text -- that validator rejects an oversize value outright (see
1243/// `validate_required_text`), it does not truncate. A caller that cuts text
1244/// with this function is responsible for setting the sibling
1245/// `ObservationV1::truncated` from the returned bool; this function only
1246/// makes the cut safe, it does not know how to report having made it.
1247pub 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        // `ProviderEvent::UsageUpdated` only ever carries `used_tokens`/
1468        // `context_window` -- the five breakdown segments arrive as `None`
1469        // and the segment-sum check does not run against them: there is
1470        // nothing sound to compare, and defaulting a missing segment to
1471        // zero would assert it was observed to be empty rather than never
1472        // measured at all.
1473        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    /// A `Granted` decision paired with a `Failed` outcome (an authorized
1563    /// host request that failed WHILE EXECUTING, e.g. a `terminal/create`
1564    /// spawn error) is a distinct, bounded, serde-stable fact -- never
1565    /// collapsed into `Denied`, and its `error` text is bounded the same
1566    /// way `ActionBlocked::reason` is.
1567    #[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    /// A `host-request-observed` record written before `outcome` existed
1615    /// (pre-K1d) -- the exact shape the durable observation checkpoint
1616    /// holds for every such record minted before this field shipped --
1617    /// carries no `outcome` field at all. `#[serde(default)]` must still
1618    /// read it back, as `HostRequestOutcomeV1::Executed`: before this field
1619    /// existed, an observed `HostRequestObserved` always meant a `Granted`
1620    /// request ran, so that is the only honest default for the old shape.
1621    /// This is the forward migration a new required field on a persisted
1622    /// type owes its old data -- see the crate's own contract for the rule.
1623    #[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    /// A `PtyHint` may report activity and nothing else: every workflow
1725    /// fact below is refused for it.
1726    ///
1727    /// Paired with the error each one actually produces, rather than
1728    /// asserting a single blanket verdict over the whole list. The blanket
1729    /// form was wrong and had been failing: `ContextWindowUsage` is checked
1730    /// by a NARROWER rule that runs first and demands
1731    /// `StructuredProvider` specifically, so it never reaches the
1732    /// pty-hint rule at all. Rejection was never in doubt -- the fixture
1733    /// was refused the whole time -- but a test that cannot say WHICH rule
1734    /// refused it is not testing that rule, and it went red rather than
1735    /// telling anyone the ordering had changed.
1736    ///
1737    /// Written as pairs, the ordering itself is now the thing under test:
1738    /// move the context-window check after the pty-hint one and this fails,
1739    /// which is the correct outcome, because the more specific diagnosis is
1740    /// the one an operator should get.
1741    #[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                // The one that is NOT refused by the pty-hint rule: its own
1789                // check sits earlier and admits only `StructuredProvider`,
1790                // so a pty hint is turned away before evidence class is
1791                // even considered as a general question.
1792                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    /// A `StructuredProvider` block may assert ANY authority, including the
1916    /// provider-side ones `HostDecisionAuthorityV1` has no vocabulary for at
1917    /// all -- the fixture from the owner's sample: a classifier block naming
1918    /// its own reason and help text.
1919    #[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    /// A `PtyHint` block may only ever assert `authority: Unknown` -- every
1950    /// other authority is refused, by the SAME rule the module doc comment
1951    /// describes: text matching may fill `reason`/`help`, never `authority`.
1952    #[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    /// `reason`/`help` are bounded and verbatim -- `validate` REJECTS an
1989    /// over-long value outright rather than silently cutting it (the same
1990    /// convention every other bounded text field in this crate follows), so
1991    /// the producer must cut with `truncate_observation_text` before
1992    /// minting. That helper cuts at a safe UTF-8 boundary and reports the
1993    /// cut so the producer can mark `ObservationV1::truncated`.
1994    #[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        // A multi-byte character sits exactly on the cut boundary -- the
2010        // truncation must back off to the last whole character, never split
2011        // it, and must still report that a cut happened.
2012        let mut boundary_straddling =
2013            "a".repeat(OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES - 1);
2014        boundary_straddling.push('€'); // 3 bytes: straddles the 1024-byte cut
2015        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}