Skip to main content

hatchery_harness_api/
lib.rs

1//! Privacy-minimized read wire contract for the harness localhost host.
2//!
3//! Each loopback TCP connection carries exactly one JSON request frame followed
4//! by `\n`. The caller must then half-close its write side; EOF is the request
5//! boundary and the host sends exactly one newline-terminated reply. A newline
6//! without the write half-close is incomplete and expires categorically as
7//! `HarnessReadHostErrorV1::Deadline`.
8
9use std::{collections::BTreeMap, fmt};
10
11pub use hatchery_harness_protocol::{
12    HarnessActorV1, HarnessApprovalLevelV1,
13    HarnessArtifactRef, HarnessEntityReadScopeV1, HarnessExecutionModeV1,
14    HarnessFailureCategoryV1, HarnessIdempotencyRef, HarnessMonitoringVisibilityV1,
15    HarnessOperationId,
16    HarnessOperationKindV1, HarnessOperationStateV1, HarnessOutcomeUnknownReasonV1,
17    HarnessCancelTaskRequestV1, HarnessCreateTaskRequestV1, HarnessDispatchIntentV1,
18    HarnessExpectedExecutionSpecRevisionV1, HarnessExecutionSpecId,
19    HarnessContinuationOutcomeUnknownReasonV1, HarnessContinuationRef,
20    HarnessContinuationStateV1, HarnessDeliveryBundleDigestV1,
21    HarnessDeliveryBundleIdV1, HarnessDeliveryBundleRevisionV1, HarnessDeliveryBundleV1,
22    HarnessDeliveryBundleSelectionV1, HarnessDeliveryComponentCountV1,
23    HarnessDeliveryComponentKindV1, HarnessContextSourceSelectionV1,
24    HarnessContextSourceAvailabilityV1,
25    HarnessDeliveryManifestDigestV2, HarnessDeliveryRef, HarnessDeliveryStateV1,
26    HarnessGrantTargetV1,
27    HarnessLaunchAuthorityRefV1, HarnessLaunchPlanRefV1, HarnessMoveTaskRequestV1,
28    HarnessOperatorAuthorityV1, HarnessReplaceTaskExecutionSpecRequestV1,
29    HarnessReplaceTaskRequestV1, HarnessRequestDigest, HarnessRetryTaskRequestV1,
30    HarnessScheduledLaunchRefV2, HarnessScheduleNextRequestV1, HarnessScheduleOutcomeV1,
31    HarnessStartTaskRequestV1, HarnessTaskExecutionSpecInputV1,
32    HarnessTaskExecutionSpecV1, HarnessTaskExecutionSpecV2,
33    HarnessTaskLaunchIssuanceId, HarnessTaskLaunchIssuanceRefV1,
34    HarnessTaskReviewPolicyV1,
35    HarnessTaskStartOutcomeV1,
36    HarnessContextPackLineageV1,
37    HarnessReadPermissionsV1, HarnessReconciliationOutcomeV1, HarnessResolvedContextPackReceiptV1,
38    HarnessResultDispositionV1,
39    HarnessInlineRef, HarnessReceiptRef, HarnessResultRef, HarnessRevision, HarnessRunId, HarnessRunIntentV1,
40    HarnessRunFinishOutcomeV1, HarnessRunFinishResultV1,
41    HarnessRunGitFactsV1, HarnessRunLifecycleV1,
42    HarnessRuntimeIdentityV1, HarnessSelectorV1, HarnessTaskId, HarnessTaskStateV1,
43    HarnessTaskCreateResultV1, HarnessTaskMoveResultV1,
44    HarnessValidationError, HarnessWorktreeIntentV1, SessionGrantId,
45    HARNESS_ARTIFACTS_MAX, HARNESS_BODY_MAX_BYTES,
46    HARNESS_CHILD_COUNT_MAX, HARNESS_CHILD_DEPTH_MAX, HARNESS_DEPENDENCIES_MAX,
47    HARNESS_CONTEXT_PACK_MAX_BYTES, HARNESS_CONTEXT_PACK_RETAINED_MESSAGES_MAX,
48    HARNESS_LINKS_MAX, HARNESS_RESULTS_MAX, HARNESS_TITLE_MAX_BYTES,
49    HARNESS_RUN_GIT_PATH_MAX_BYTES,
50};
51use serde::{Deserialize, Deserializer, Serialize};
52use thiserror::Error;
53
54pub const HARNESS_READ_REQUEST_MAX_BYTES: usize = 64 * 1024;
55pub const HARNESS_READ_RESPONSE_MAX_BYTES: usize = 1024 * 1024;
56pub const HARNESS_READ_LIMIT_MAX: u16 = 256;
57pub const HARNESS_ENTITY_PAGE_LIMIT_MAX: u16 = 64;
58pub const HARNESS_TIMELINE_PAGE_LIMIT_MAX: u16 = 128;
59pub const HARNESS_MONITOR_FACTS_MAX: usize = 128;
60pub const HARNESS_OBSERVATION_LABEL_MAX_BYTES: usize = 64;
61pub const HARNESS_OBSERVATION_TODO_TEXT_MAX_BYTES: usize = 256;
62pub const HARNESS_OBSERVATION_PATH_MAX_BYTES: usize = 1_024;
63/// Max bytes for `BlockFactV1::label` (`<authority slug>: <reason>`,
64/// producer-truncated to fit) -- sized like
65/// `HARNESS_OBSERVATION_TODO_TEXT_MAX_BYTES` rather than
66/// `HARNESS_OBSERVATION_LABEL_MAX_BYTES`: a block's `reason` is verbatim
67/// provider/gate prose, bounded at 1024 bytes where it is minted (see
68/// `hatchery_observation_protocol::OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES`),
69/// not a short class slug like "bash"/"editor" the 64-byte activity-label
70/// bound was sized for.
71pub const HARNESS_OBSERVATION_BLOCK_LABEL_MAX_BYTES: usize = 256;
72pub const HARNESS_READ_CREDENTIAL_MAX_BYTES: usize = 8 * 1024;
73/// Credential audience for harness MCP read grants.
74///
75/// Wire string kept `gate4agent-harness-mcp-read-v1` until §11.4 wave E
76/// (digest/audience cutover). Deferred alias: `hatchery-harness-mcp-read-v1`.
77/// Do not rename alone — invalidates issued credentials.
78pub const HARNESS_MCP_AUDIENCE: &str = "gate4agent-harness-mcp-read-v1";
79/// The harness operator wire is a loopback protocol between processes built
80/// from the same tree and rolled together (`hatchery-harness` and its two
81/// clients, `hatchery-tui` / `hatchery-tui-light`, via
82/// `hatchery-harness-client`). There is exactly one peer shape at a time --
83/// no peer that cannot be rebuilt exists -- so the envelope now checks
84/// [`BUILD_STAMP`] for exact equality instead of a hand-typed version
85/// counter: `BUILD_STAMP` is a content hash of the working tree computed at
86/// compile time (see `gate4agent-build-stamp`), so any wire-shape change
87/// anywhere in the co-built tree moves it automatically, and a peer built
88/// from a byte-identical checkout always matches. An additive read-wire
89/// field still just needs `#[serde(default)]`, same as before -- nothing
90/// about that discipline changes. A persisted format version (a checkpoint,
91/// database schema, or any other record that must survive forward migration
92/// across releases) is a different thing entirely and keeps its own
93/// explicit, hand-bumped integer.
94pub use gate4agent_build_stamp::BUILD_STAMP;
95// Realistic multi-pane ceiling with headroom; bounds the harness-side
96// per-subscriber HashSet<RuntimeSessionKey> and the connect-time seed burst
97// `SubscribeTerminal`'s handler sends immediately after registering (see
98// `hatchery-harness-service::runtime`'s `HostCommand::SubscribeTerminal`
99// arm).
100pub const HARNESS_TERMINAL_SUBSCRIPTION_SESSIONS_MAX: usize = 32;
101// Same rationale as immediately above, for `SubscribeAgentStream` -- bounds
102// the harness-side per-subscriber HashSet<RuntimeSessionKey> and the
103// connect-time seed burst its handler sends immediately after registering.
104// A distinct constant from the terminal one even though the value matches:
105// the two subscription kinds track separate per-subscriber sets and could
106// diverge later without forcing a shared bound.
107pub const HARNESS_AGENT_STREAM_SUBSCRIPTION_SESSIONS_MAX: usize = 32;
108pub const HARNESS_OPERATOR_REQUEST_MAX_BYTES: usize = 64 * 1024;
109pub const HARNESS_OPERATOR_RESPONSE_MAX_BYTES: usize = 1024 * 1024;
110pub const HARNESS_OPERATOR_CREDENTIAL_MAX_BYTES: usize = 256;
111pub const HARNESS_RUNTIME_INVENTORY_PAGE_LIMIT_MAX: u16 = 64;
112pub const HARNESS_NATIVE_SESSION_CATALOG_LIMIT_MAX: u16 = 64;
113pub const HARNESS_NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX: u16 = 24;
114pub const HARNESS_NATIVE_SESSION_PREVIEW_TEXT_MAX_BYTES: usize = 4_096;
115pub const HARNESS_SESSION_RECORD_DISPLAY_NAME_MAX_BYTES: usize = 256;
116pub const HARNESS_SESSION_RECORD_TASK_ID_MAX_BYTES: usize = 128;
117pub const HARNESS_PROVIDER_SESSION_ID_MAX_BYTES: usize = 512;
118pub const HARNESS_PROVIDER_SESSION_TRANSCRIPT_PATH_MAX_BYTES: usize = 32_768;
119pub const HARNESS_LAUNCH_PLAN_PAGE_LIMIT_MAX: u16 = 64;
120pub const HARNESS_TASK_LAUNCH_OPTIONS_MAX: usize = 64;
121/// Bounds `HarnessTaskLaunchOptionsV1::context_source_exclusions` -- a
122/// diagnostic dump of every run `context_source_option` declined to surface,
123/// not an operator-facing selection page, so it gets its own smaller ceiling
124/// rather than reusing [`HARNESS_TASK_LAUNCH_OPTIONS_MAX`].
125pub const HARNESS_CONTEXT_SOURCE_EXCLUSIONS_MAX: usize = 32;
126/// Bounds `ContextSourceExclusionV1::NodeIncarnationUnknown`'s own
127/// `known_incarnations` list -- the incarnation(s) the runtime inventory
128/// currently holds for the excluded run's `node_id`.
129pub const CONTEXT_SOURCE_EXCLUSION_KNOWN_INCARNATIONS_MAX: usize = 8;
130pub const HARNESS_RUNTIME_SPAWN_PROFILES_MAX: usize = 64;
131pub const HARNESS_RUNTIME_LAUNCH_BUNDLES_MAX: usize = 128;
132pub const HARNESS_REPOSITORY_PATH_MAX_BYTES: usize = 1_024;
133pub const HARNESS_WORKSPACE_FILE_MAX_BYTES: usize = 256 * 1_024;
134pub const HARNESS_WORKSPACE_TREE_ENTRIES_MAX: usize = 512;
135pub const HARNESS_GIT_STATUS_ENTRIES_MAX: usize = 128;
136pub const HARNESS_GIT_RECENT_COMMITS_MAX: usize = 12;
137pub const HARNESS_GIT_HISTORY_LIMIT_MAX: u16 = 50;
138pub const HARNESS_GIT_DIFF_MAX_BYTES: usize = 512 * 1_024;
139pub const HARNESS_GIT_COMMIT_PARENTS_MAX: usize = 32;
140pub const HARNESS_GIT_SUMMARY_MAX_BYTES: usize = 1_024;
141pub const HARNESS_GIT_IDENTITY_MAX_BYTES: usize = 512;
142pub const HARNESS_GIT_TIMESTAMP_MAX_BYTES: usize = 128;
143pub const HARNESS_GIT_SIGNER_MAX_BYTES: usize = 1_024;
144pub const HARNESS_REVERSE_ATTRIBUTION_LINKS_MAX: usize = 64;
145pub const HARNESS_TERMINAL_PAGE_LIMIT_MAX: u16 = 64;
146pub const HARNESS_TERMINAL_SCROLLBACK_LINES_MAX: usize = 512;
147// Ceiling for one wire terminal frame. Must stay above the node's real maximum
148// live frame: PTY_TERMINAL_SCROLLBACK_ROWS_MAX (256) styled rows plus the screen,
149// which on a wide, heavily styled terminal can exceed 512 KiB. 2 MiB gives
150// headroom so validate() never rejects a legitimate frame while still bounding a
151// malformed one.
152pub const HARNESS_TERMINAL_FRAME_MAX_BYTES: usize = 2 * 1_024 * 1_024;
153// Matches the node's own `MAX_NODE_TEXT_BYTES` (gate4agent-node-protocol):
154// this crate has no dependency on that crate, so the bound is mirrored here
155// as the wire-level ceiling `WriteSessionInput` is rejected above; the node
156// re-checks the same limit authoritatively on its own side regardless.
157// `PromptSession`/`PasteSession` reuse this exact constant: the node's own
158// `validate_node_text` checks "prompt"/"paste"/"terminal input" against the
159// same single `MAX_NODE_TEXT_BYTES` ceiling, not a per-verb one.
160pub const HARNESS_SESSION_INPUT_MAX_BYTES: usize = 32 * 1_024;
161// Matches the node's own `MAX_NODE_TERMINAL_BYTES` (gate4agent-node-protocol)
162// / `gate4agent_types::TERMINAL_BYTES_MAX_BYTES`, mirrored here for the same
163// reason as `HARNESS_SESSION_INPUT_MAX_BYTES` above: this is the wire-level
164// ceiling `WriteSessionBytes` is rejected above, not a substitute for the
165// node's own authoritative re-check.
166pub const HARNESS_SESSION_BYTES_MAX_BYTES: usize = 64;
167// Matches the node's own `MAX_WORKSPACE_ROOT_BYTES` (gate4agent-node-protocol,
168// itself `gate4agent_types::WORKING_DIRECTORY_MAX_BYTES`): the wire-level
169// ceiling `HarnessHostPathV1` (a host filesystem path -- workspace/worktree
170// root, host-directory-browse cursor) is rejected above. Not shared with
171// `HARNESS_REPOSITORY_PATH_MAX_BYTES`: that bound is for repo-relative paths,
172// a categorically smaller and differently-shaped value.
173pub const HARNESS_HOST_PATH_MAX_BYTES: usize = 32 * 1_024;
174// Matches the node's own `MAX_HOST_DIRECTORY_ENTRIES`/
175// `MAX_HOST_DIRECTORY_DISPLAY_NAME_BYTES` (gate4agent-node-protocol).
176pub const HARNESS_HOST_DIRECTORY_ENTRIES_MAX: usize = 256;
177pub const HARNESS_HOST_DIRECTORY_DISPLAY_NAME_MAX_BYTES: usize = 1_024;
178// Mirrors `gate4agent-node-protocol::MAX_ACP_CONTROL_ID_BYTES`
179// (`gate4agent_types::PROVIDER_EVENT_ID_MAX_BYTES`): this crate has no
180// dependency on either (see the doc comment on `HarnessTerminalControlV1`),
181// so the ACP setter verbs' `mode_id`/`option_id`/`model_id` and the agent
182// stream's `tool_name`/catalog id fields are bounded to the same ceiling the
183// provider event stream already validates them against.
184pub const HARNESS_AGENT_STREAM_ID_MAX_BYTES: usize = 512;
185// Mirrors `gate4agent-node-protocol::MAX_ACP_CONTROL_TEXT_BYTES`
186// (`gate4agent_types::PROVIDER_EVENT_TEXT_MAX_BYTES`): free text carried on
187// the agent content stream (`Text`, `Thinking`, interaction `prompt`/
188// `title`, catalog entry descriptions) and `SetSessionConfigOption`'s
189// `value_json`.
190pub const HARNESS_AGENT_STREAM_TEXT_MAX_BYTES: usize = 262_144;
191// Mirrors `gate4agent-node-protocol::MAX_ACP_INTERACTION_OPTIONS`
192// (`gate4agent_types::PROVIDER_CONFIG_OPTION_CHOICES_MAX`): the option list
193// on one `InteractionPrompt` chunk, and (the same source constant reused for
194// the same reason on the node side) the `choices` list on one
195// `ConfigOptions` entry.
196pub const HARNESS_AGENT_STREAM_INTERACTION_OPTIONS_MAX: usize = 256;
197// Mirrors `gate4agent-node-protocol::MAX_ACP_CATALOG_ENTRIES`
198// (`gate4agent_types::PROVIDER_CONFIG_OPTIONS_MAX`): the catalog list on a
199// `ModeCatalog`/`ConfigOptions`/`ModelCatalog` chunk.
200pub const HARNESS_AGENT_STREAM_CATALOG_ENTRIES_MAX: usize = 256;
201// Mirrors `gate4agent_types::PROVIDER_INTERACTION_RESPONSE_MAX_BYTES`: the
202// bound on `ResolveInteraction`'s `Answer { text }` payload. This wire does
203// not also mirror that source type's `EmptyAnswer` rule (an `Answer` must be
204// non-empty only when the live interaction is a `Question`) -- see
205// `HarnessProviderInteractionResponseV1`'s own doc comment for why that
206// check stays the node's job.
207pub const HARNESS_ACP_INTERACTION_RESPONSE_MAX_BYTES: usize = 32_768;
208
209pub const HARNESS_READ_TOOL_IDS: [&str; 8] = [
210    "g4a_context_get",
211    "g4a_monitor_get",
212    "g4a_timeline_read",
213    "g4a_tasks_list",
214    "g4a_tasks_get",
215    "g4a_runs_list",
216    "g4a_runs_get",
217    "g4a_operation_get",
218];
219
220/// Slice D's `g4a_task_create`/`g4a_task_move` (D5) and S10's
221/// `g4a_run_finish`. Kept separate from `HARNESS_READ_TOOL_IDS` rather than
222/// folded into it -- that array's own test
223/// (`all_eight_tool_schemas_are_stable_and_closed`) pins it as exactly the
224/// eight read-only tools zipped in `tool_definitions()`'s declared order.
225/// `g4a_task_create`/`g4a_task_move` are gated by
226/// `grant.task_permissions.create`/`.mutate` respectively. `g4a_run_finish`
227/// is the one member of this array gated by no grant permission at all
228/// (`expected_allowed_tool_ids` carries it unconditionally, the same way it
229/// carries `g4a_context_get` out of `HARNESS_READ_TOOL_IDS`) -- every
230/// session needs the ability to report its own work finished regardless of
231/// what else its grant allows. D7: this array, and `HARNESS_READ_TOOL_IDS`,
232/// are the only two names `tools/call` ever admits; `ResolveInteraction`
233/// (or any other name) is refused by name regardless of what a grant's
234/// `allowed_tool_ids` claims.
235pub const HARNESS_WRITE_TOOL_IDS: [&str; 3] = [
236    "g4a_run_finish",
237    "g4a_task_create",
238    "g4a_task_move",
239];
240
241const TOKEN_PREFIX: &str = "g4ah2_";
242const OPERATOR_TOKEN_PREFIX: &str = "g4aho_";
243const OPERATOR_REQUEST_REF_PREFIX: &str = "hireq_";
244
245#[derive(Clone, Eq, PartialEq)]
246pub struct HarnessReadCredential(String);
247
248impl HarnessReadCredential {
249    pub fn parse(value: impl Into<String>) -> Result<Self, HarnessReadApiError> {
250        let value = value.into();
251        validate_credential(&value)?;
252        Ok(Self(value))
253    }
254
255    pub fn expose(&self) -> &str { &self.0 }
256}
257
258impl fmt::Debug for HarnessReadCredential {
259    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
260        formatter.write_str("HarnessReadCredential([REDACTED])")
261    }
262}
263
264impl Serialize for HarnessReadCredential {
265    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
266    where S: serde::Serializer {
267        serializer.serialize_str(&self.0)
268    }
269}
270
271impl<'de> Deserialize<'de> for HarnessReadCredential {
272    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
273    where D: Deserializer<'de> {
274        Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
275    }
276}
277
278#[derive(Clone, Eq, PartialEq)]
279pub struct HarnessOperatorCredential(String);
280
281impl HarnessOperatorCredential {
282    pub fn parse(value: impl Into<String>) -> Result<Self, HarnessOperatorApiError> {
283        let value = value.into();
284        validate_operator_credential(&value)?;
285        Ok(Self(value))
286    }
287
288    pub fn expose(&self) -> &str { &self.0 }
289}
290
291impl fmt::Debug for HarnessOperatorCredential {
292    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293        formatter.write_str("HarnessOperatorCredential([REDACTED])")
294    }
295}
296
297impl Serialize for HarnessOperatorCredential {
298    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
299    where S: serde::Serializer {
300        serializer.serialize_str(&self.0)
301    }
302}
303
304impl<'de> Deserialize<'de> for HarnessOperatorCredential {
305    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
306    where D: Deserializer<'de> {
307        Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
308    }
309}
310
311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
312#[serde(deny_unknown_fields)]
313pub struct HarnessOperatorEnvelopeV1 {
314    pub build_stamp: String,
315    pub credential: HarnessOperatorCredential,
316    pub request: HarnessOperatorRequestV1,
317}
318
319impl HarnessOperatorEnvelopeV1 {
320    /// Builds an envelope carrying this binary's own [`BUILD_STAMP`] -- the
321    /// one place every caller in this tree (this crate's own tests,
322    /// `hatchery-harness-service`, `hatchery-harness-client`) should
323    /// build one from, rather than hand-filling `build_stamp` at each site.
324    pub fn new(credential: HarnessOperatorCredential, request: HarnessOperatorRequestV1) -> Self {
325        Self {
326            build_stamp: BUILD_STAMP.to_string(),
327            credential,
328            request,
329        }
330    }
331
332    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
333        if self.build_stamp != BUILD_STAMP {
334            return Err(HarnessOperatorApiError::BuildStampMismatch {
335                expected: BUILD_STAMP.to_string(),
336                received: self.build_stamp.clone(),
337            });
338        }
339        validate_operator_credential(self.credential.expose())?;
340        self.request.validate()
341    }
342}
343
344#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
345#[serde(transparent)]
346pub struct HarnessOperatorRequestRefV1(String);
347
348impl HarnessOperatorRequestRefV1 {
349    pub fn new(value: impl Into<String>) -> Result<Self, HarnessOperatorApiError> {
350        let value = value.into();
351        validate_operator_request_ref(&value)?;
352        Ok(Self(value))
353    }
354
355    pub fn as_str(&self) -> &str { &self.0 }
356
357    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
358        validate_operator_request_ref(&self.0)
359    }
360}
361
362impl<'de> Deserialize<'de> for HarnessOperatorRequestRefV1 {
363    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
364    where
365        D: Deserializer<'de>,
366    {
367        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
368    }
369}
370
371#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
372#[serde(deny_unknown_fields)]
373pub struct HarnessOperatorIntentV1 {
374    pub request_ref: HarnessOperatorRequestRefV1,
375    pub submitted_at_unix_ms: u64,
376    pub action: HarnessOperatorActionV1,
377}
378
379impl HarnessOperatorIntentV1 {
380    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
381        self.request_ref.validate()?;
382        if self.submitted_at_unix_ms == 0 {
383            return Err(HarnessOperatorApiError::InvalidSubmittedAt);
384        }
385        let authority = HarnessOperatorAuthorityV1 {
386            operation_id: HarnessOperationId::new(format!("hop_{}", "0".repeat(24)))
387                .map_err(HarnessOperatorApiError::Protocol)?,
388            idempotency_ref: HarnessIdempotencyRef::new(format!("hidem_{}", "0".repeat(24)))
389                .map_err(HarnessOperatorApiError::Protocol)?,
390            actor_id: HarnessSelectorV1::new("harness-operator")
391                .map_err(HarnessOperatorApiError::Protocol)?,
392            now_unix_ms: self.submitted_at_unix_ms,
393        };
394        let task_id = HarnessTaskId::new(format!("htask_{}", "0".repeat(24)))
395            .map_err(HarnessOperatorApiError::Protocol)?;
396        self.action.clone().authorize(authority, task_id).validate()
397    }
398
399    pub fn authorize(
400        self,
401        authority: HarnessOperatorAuthorityV1,
402        create_task_id: HarnessTaskId,
403    ) -> HarnessOperatorRequestV1 {
404        self.action.authorize(authority, create_task_id)
405    }
406}
407
408#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
409#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
410pub enum HarnessOperatorActionV1 {
411    CreateTask {
412        title: String,
413        body: String,
414        parent_task_id: Option<HarnessTaskId>,
415        dependencies: Vec<HarnessTaskId>,
416        initial_state: HarnessTaskStateV1,
417    },
418    ReplaceTask {
419        task_id: HarnessTaskId,
420        expected_revision: HarnessRevision,
421        title: String,
422        body: String,
423        parent_task_id: Option<HarnessTaskId>,
424        dependencies: Vec<HarnessTaskId>,
425    },
426    MoveTask {
427        task_id: HarnessTaskId,
428        expected_revision: HarnessRevision,
429        state: HarnessTaskStateV1,
430    },
431    CancelTask {
432        task_id: HarnessTaskId,
433        expected_revision: HarnessRevision,
434    },
435    RetryTask {
436        task_id: HarnessTaskId,
437        expected_revision: HarnessRevision,
438    },
439    ScheduleNext {
440        plan_id: Option<HarnessSelectorV1>,
441    },
442    ReplaceTaskExecutionSpec {
443        task_id: HarnessTaskId,
444        expected_task_revision: HarnessRevision,
445        expected_execution_spec_revision: HarnessExpectedExecutionSpecRevisionV1,
446        spec: HarnessTaskExecutionSpecInputV1,
447    },
448    StartTask {
449        task_id: HarnessTaskId,
450        expected_task_revision: HarnessRevision,
451        expected_execution_spec_revision: HarnessRevision,
452        expected_scheduled_launch_digest: HarnessRequestDigest,
453    },
454    ReplaceTaskExecutionSpecV2 {
455        task_id: HarnessTaskId,
456        expected_task_revision: HarnessRevision,
457        expected_execution_spec_revision: HarnessExpectedExecutionSpecRevisionV1,
458        selection: HarnessReviewedTaskLaunchSelectionV1,
459    },
460    StartTaskV2 {
461        task_id: HarnessTaskId,
462        expected_task_revision: HarnessRevision,
463        expected_execution_spec_revision: HarnessRevision,
464        expected_launch_issuance: HarnessTaskLaunchIssuanceRefV1,
465    },
466}
467
468impl HarnessOperatorActionV1 {
469    pub fn authorize(
470        self,
471        authority: HarnessOperatorAuthorityV1,
472        create_task_id: HarnessTaskId,
473    ) -> HarnessOperatorRequestV1 {
474        match self {
475            Self::CreateTask {
476                title,
477                body,
478                parent_task_id,
479                dependencies,
480                initial_state,
481            } => HarnessOperatorRequestV1::CreateTask {
482                request: HarnessCreateTaskRequestV1 {
483                    authority,
484                    task_id: create_task_id,
485                    title,
486                    body,
487                    parent_task_id,
488                    dependencies,
489                    initial_state,
490                },
491            },
492            Self::ReplaceTask {
493                task_id,
494                expected_revision,
495                title,
496                body,
497                parent_task_id,
498                dependencies,
499            } => HarnessOperatorRequestV1::ReplaceTask {
500                request: HarnessReplaceTaskRequestV1 {
501                    authority,
502                    task_id,
503                    expected_revision,
504                    title,
505                    body,
506                    parent_task_id,
507                    dependencies,
508                },
509            },
510            Self::MoveTask { task_id, expected_revision, state } => {
511                HarnessOperatorRequestV1::MoveTask {
512                    request: HarnessMoveTaskRequestV1 {
513                        authority,
514                        task_id,
515                        expected_revision,
516                        state,
517                    },
518                }
519            }
520            Self::CancelTask { task_id, expected_revision } => {
521                HarnessOperatorRequestV1::CancelTask {
522                    request: HarnessCancelTaskRequestV1 {
523                        authority,
524                        task_id,
525                        expected_revision,
526                    },
527                }
528            }
529            Self::RetryTask { task_id, expected_revision } => {
530                HarnessOperatorRequestV1::RetryTask {
531                    request: HarnessRetryTaskRequestV1 {
532                        authority,
533                        task_id,
534                        expected_revision,
535                    },
536                }
537            }
538            Self::ScheduleNext { plan_id } => HarnessOperatorRequestV1::ScheduleNext {
539                request: HarnessScheduleNextRequestV1 { authority, plan_id },
540            },
541            Self::ReplaceTaskExecutionSpec {
542                task_id,
543                expected_task_revision,
544                expected_execution_spec_revision,
545                spec,
546            } => HarnessOperatorRequestV1::ReplaceTaskExecutionSpec {
547                request: HarnessReplaceTaskExecutionSpecRequestV1 {
548                    authority,
549                    task_id,
550                    expected_task_revision,
551                    expected_execution_spec_revision,
552                    spec,
553                },
554            },
555            Self::StartTask {
556                task_id,
557                expected_task_revision,
558                expected_execution_spec_revision,
559                expected_scheduled_launch_digest,
560            } => HarnessOperatorRequestV1::StartTask {
561                request: HarnessStartTaskRequestV1 {
562                    authority,
563                    task_id,
564                    expected_task_revision,
565                    expected_execution_spec_revision,
566                    expected_scheduled_launch_digest,
567                },
568            },
569            Self::ReplaceTaskExecutionSpecV2 {
570                task_id,
571                expected_task_revision,
572                expected_execution_spec_revision,
573                selection,
574            } => HarnessOperatorRequestV1::ReplaceTaskExecutionSpecV2 {
575                request: HarnessReplaceTaskExecutionSpecRequestV2 {
576                    authority,
577                    task_id,
578                    expected_task_revision,
579                    expected_execution_spec_revision,
580                    selection,
581                },
582            },
583            Self::StartTaskV2 {
584                task_id,
585                expected_task_revision,
586                expected_execution_spec_revision,
587                expected_launch_issuance,
588            } => HarnessOperatorRequestV1::StartTaskV2 {
589                request: HarnessStartTaskRequestV2 {
590                    authority,
591                    task_id,
592                    expected_task_revision,
593                    expected_execution_spec_revision,
594                    expected_launch_issuance,
595                },
596            },
597        }
598    }
599
600}
601
602#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
603#[serde(deny_unknown_fields)]
604pub struct HarnessLaunchPlanSummaryV1 {
605    pub scheduled_launch: HarnessScheduledLaunchRefV2,
606    pub node_id: HarnessSelectorV1,
607    pub workspace_id: HarnessSelectorV1,
608    pub worktree: HarnessWorktreeIntentV1,
609    pub provider_profile: HarnessSelectorV1,
610    pub provider_id: HarnessSelectorV1,
611    pub mode: HarnessExecutionModeV1,
612}
613
614impl HarnessLaunchPlanSummaryV1 {
615    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
616        self.scheduled_launch.validate().map_err(HarnessOperatorApiError::Protocol)?;
617        self.node_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
618        self.workspace_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
619        self.worktree.validate().map_err(HarnessOperatorApiError::Protocol)?;
620        self.provider_profile.validate().map_err(HarnessOperatorApiError::Protocol)?;
621        self.provider_id.validate().map_err(HarnessOperatorApiError::Protocol)
622    }
623}
624
625#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
626#[serde(deny_unknown_fields)]
627pub struct HarnessLaunchPlanPageV1 {
628    pub plans: Vec<HarnessLaunchPlanSummaryV1>,
629    pub next_plan_id: Option<HarnessSelectorV1>,
630}
631
632impl HarnessLaunchPlanPageV1 {
633    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
634        if self.plans.len() > usize::from(HARNESS_LAUNCH_PLAN_PAGE_LIMIT_MAX) {
635            return Err(HarnessOperatorApiError::InvalidLaunchPlans);
636        }
637        for plan in &self.plans { plan.validate()?; }
638        if self.plans.windows(2).any(|plans| {
639            plans[0].scheduled_launch.plan.plan_id.as_str()
640                >= plans[1].scheduled_launch.plan.plan_id.as_str()
641        }) {
642            return Err(HarnessOperatorApiError::InvalidLaunchPlans);
643        }
644        if let Some(next_plan_id) = &self.next_plan_id {
645            next_plan_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
646            if self.plans.last().map(|plan| &plan.scheduled_launch.plan.plan_id)
647                != Some(next_plan_id)
648            {
649                return Err(HarnessOperatorApiError::InvalidLaunchPlans);
650            }
651        }
652        Ok(())
653    }
654}
655
656#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
657#[serde(deny_unknown_fields)]
658pub struct HarnessOrdinaryLaunchPlanOptionV1 {
659    pub plan: HarnessLaunchPlanRefV1,
660    pub node_id: HarnessSelectorV1,
661    pub source_workspace_id: HarnessSelectorV1,
662    pub provider_profile: HarnessSelectorV1,
663    pub provider_id: HarnessSelectorV1,
664    pub mode: HarnessExecutionModeV1,
665}
666
667impl HarnessOrdinaryLaunchPlanOptionV1 {
668    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
669        self.plan.validate().map_err(HarnessOperatorApiError::Protocol)?;
670        self.node_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
671        self.source_workspace_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
672        self.provider_profile.validate().map_err(HarnessOperatorApiError::Protocol)?;
673        self.provider_id.validate().map_err(HarnessOperatorApiError::Protocol)
674    }
675}
676
677#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
678#[serde(rename_all = "kebab-case")]
679pub enum HarnessManagedWorktreeRetentionV1 {
680    RemoveWhenReleased,
681    Retain,
682}
683
684#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
685#[serde(deny_unknown_fields)]
686pub struct HarnessManagedWorktreeProfileOptionV1 {
687    pub node_id: HarnessSelectorV1,
688    pub node_incarnation: HarnessSelectorV1,
689    pub source_workspace_id: HarnessSelectorV1,
690    pub profile_id: HarnessSelectorV1,
691    pub profile_revision: HarnessSelectorV1,
692    pub retention: HarnessManagedWorktreeRetentionV1,
693    pub observed_at_unix_ms: u64,
694}
695
696impl HarnessManagedWorktreeProfileOptionV1 {
697    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
698        self.node_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
699        self.node_incarnation.validate().map_err(HarnessOperatorApiError::Protocol)?;
700        self.source_workspace_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
701        self.profile_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
702        self.profile_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
703        if self.observed_at_unix_ms == 0 {
704            return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
705        }
706        Ok(())
707    }
708}
709
710#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
711#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
712pub enum HarnessReviewedWorktreeSelectionV1 {
713    Existing,
714    Managed { profile: HarnessManagedWorktreeProfileOptionV1 },
715}
716
717impl HarnessReviewedWorktreeSelectionV1 {
718    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
719        match self {
720            Self::Existing => Ok(()),
721            Self::Managed { profile } => profile.validate(),
722        }
723    }
724}
725
726#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
727#[serde(deny_unknown_fields)]
728pub struct HarnessReviewedTaskLaunchSelectionV1 {
729    pub plan: HarnessOrdinaryLaunchPlanOptionV1,
730    pub worktree: HarnessReviewedWorktreeSelectionV1,
731    pub context_source: Option<HarnessContextSourceSelectionV1>,
732    pub delivery: Option<HarnessDeliveryBundleSelectionV1>,
733    pub review_policy: HarnessTaskReviewPolicyV1,
734}
735
736impl HarnessReviewedTaskLaunchSelectionV1 {
737    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
738        self.plan.validate()?;
739        self.worktree.validate()?;
740        if let HarnessReviewedWorktreeSelectionV1::Managed { profile } = &self.worktree {
741            if profile.node_id != self.plan.node_id
742                || profile.source_workspace_id != self.plan.source_workspace_id
743            {
744                return Err(HarnessOperatorApiError::InvalidTaskLaunchSelection);
745            }
746        }
747        if let Some(context_source) = &self.context_source {
748            context_source.validate().map_err(HarnessOperatorApiError::Protocol)?;
749        }
750        if let Some(delivery) = &self.delivery {
751            delivery.validate().map_err(HarnessOperatorApiError::Protocol)?;
752        }
753        Ok(())
754    }
755}
756
757#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
758#[serde(deny_unknown_fields)]
759pub struct HarnessIssuedExecutionSpecSummaryV1 {
760    pub task_id: HarnessTaskId,
761    pub execution_spec_id: HarnessExecutionSpecId,
762    pub revision: HarnessRevision,
763    pub launch_issuance: HarnessTaskLaunchIssuanceRefV1,
764    pub review_policy: HarnessTaskReviewPolicyV1,
765    pub created_at_unix_ms: u64,
766    pub updated_at_unix_ms: u64,
767}
768
769impl HarnessIssuedExecutionSpecSummaryV1 {
770    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
771        if self.launch_issuance.revision != self.revision {
772            return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
773        }
774        HarnessTaskExecutionSpecV2 {
775            execution_spec_id: self.execution_spec_id.clone(),
776            revision: self.revision,
777            task_id: self.task_id.clone(),
778            launch_issuance: self.launch_issuance.clone(),
779            review_policy: self.review_policy,
780            created_at_unix_ms: self.created_at_unix_ms,
781            updated_at_unix_ms: self.updated_at_unix_ms,
782        }.validate().map_err(HarnessOperatorApiError::Protocol)
783    }
784}
785
786/// Names, with its compared inputs, the exact reason `context_source_option`
787/// (`hatchery-harness-service::runtime`) declined to surface a run as a
788/// `context_sources` candidate. Instrumentation only, never a policy
789/// decision -- the admission logic itself is unchanged by this type's
790/// existence; it only makes an already-silent exclusion observable from
791/// `launch-options` and the harness's own logs (`context source excluded`).
792/// One variant per early exit in that function, Live and Durable branches
793/// alike; a Live-branch-only or Durable-branch-only condition says so in its
794/// own doc comment.
795#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
796#[serde(tag = "reason", rename_all = "kebab-case", deny_unknown_fields)]
797pub enum ContextSourceExclusionV1 {
798    /// The run's `lifecycle` is neither `Running` nor `Waiting`, and it
799    /// carries no durable context pack (which would have taken the Durable
800    /// branch instead of this lifecycle gate).
801    LifecycleNotLive { lifecycle: HarnessRunLifecycleV1 },
802    /// The run carries no `binding` at all -- Live or Durable branch alike.
803    NoBinding,
804    /// The bound session identity is not `Managed` at all (Durable branch),
805    /// or (Live branch) it is `Managed` with no `active_session`.
806    NoActiveManagedSession,
807    /// Live branch only: no entry in the runtime inventory carries this
808    /// run's exact `node_id` + `node_incarnation` pair (the Durable branch
809    /// re-resolves the node's CURRENT incarnation by `node_id` alone
810    /// instead; see `DurableNodeUnknown`). `known_incarnations` names the
811    /// incarnation(s) the inventory DOES hold for that `node_id`, bounded at
812    /// [`CONTEXT_SOURCE_EXCLUSION_KNOWN_INCARNATIONS_MAX`].
813    NodeIncarnationUnknown {
814        node_id: HarnessSelectorV1,
815        node_incarnation: HarnessSelectorV1,
816        known_incarnations: Vec<HarnessSelectorV1>,
817    },
818    /// The matched node's `managed_sessions` page carries no record whose
819    /// `record_id`/`workspace_id`/`active_binding` (workspace, instance,
820    /// generation) match the run's binding, and that page was NOT cut for
821    /// size -- the record genuinely does not exist on this node right now.
822    /// `node_has_records` is the page's own length, for scale.
823    ManagedSessionRecordMismatch {
824        record_id: HarnessSelectorV1,
825        workspace_id: HarnessSelectorV1,
826        instance_id: u64,
827        generation: u64,
828        node_has_records: u32,
829    },
830    /// Same missing-record shape as `ManagedSessionRecordMismatch`, but the
831    /// node's `managed_sessions_truncated` flag was set: the page the
832    /// harness received is a PREFIX of the node's full record set, so the
833    /// matching record may simply sit past that cut rather than genuinely be
834    /// absent. `page_len` is the page's own length, `total` the node's full
835    /// `managed_session_count`.
836    ManagedSessionsPageTruncated {
837        record_id: HarnessSelectorV1,
838        page_len: u32,
839        total: u32,
840    },
841    /// Live branch only: `execute_operator_monitor` returned an error for
842    /// this run.
843    MonitorUnavailable,
844    /// Live branch only: the monitor projection resolved but is not live
845    /// enough to route an export through right now.
846    ProjectionNotLive {
847        availability: ProjectionAvailabilityV1,
848        freshness: ProjectionFreshnessV1,
849        transport_incomplete: bool,
850    },
851    /// Durable branch only: the pack's `node_id` is not currently known by
852    /// the runtime inventory at all (any incarnation), so there is no route
853    /// to resolve the pack through right now.
854    DurableNodeUnknown { node_id: HarnessSelectorV1 },
855}
856
857impl ContextSourceExclusionV1 {
858    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
859        match self {
860            Self::LifecycleNotLive { .. }
861            | Self::NoBinding
862            | Self::NoActiveManagedSession
863            | Self::MonitorUnavailable
864            | Self::ProjectionNotLive { .. } => Ok(()),
865            Self::NodeIncarnationUnknown { node_id, node_incarnation, known_incarnations } => {
866                node_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
867                node_incarnation.validate().map_err(HarnessOperatorApiError::Protocol)?;
868                if known_incarnations.len() > CONTEXT_SOURCE_EXCLUSION_KNOWN_INCARNATIONS_MAX {
869                    return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
870                }
871                for incarnation in known_incarnations {
872                    incarnation.validate().map_err(HarnessOperatorApiError::Protocol)?;
873                }
874                Ok(())
875            }
876            Self::ManagedSessionRecordMismatch {
877                record_id, workspace_id, instance_id, generation, ..
878            } => {
879                record_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
880                workspace_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
881                if *instance_id == 0 || *generation == 0 {
882                    return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
883                }
884                Ok(())
885            }
886            Self::ManagedSessionsPageTruncated { record_id, page_len, total } => {
887                record_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
888                if total < page_len {
889                    return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
890                }
891                Ok(())
892            }
893            Self::DurableNodeUnknown { node_id } => {
894                node_id.validate().map_err(HarnessOperatorApiError::Protocol)
895            }
896        }
897    }
898}
899
900/// One [`ContextSourceExclusionV1`] pinned to the run it was computed for --
901/// `HarnessTaskLaunchOptionsV1::context_source_exclusions`'s element type.
902#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
903#[serde(deny_unknown_fields)]
904pub struct ContextSourceExclusionEntryV1 {
905    pub run_id: HarnessRunId,
906    pub exclusion: ContextSourceExclusionV1,
907}
908
909impl ContextSourceExclusionEntryV1 {
910    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
911        self.run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
912        self.exclusion.validate()
913    }
914}
915
916#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
917#[serde(deny_unknown_fields)]
918pub struct HarnessTaskLaunchOptionsV1 {
919    pub task_id: HarnessTaskId,
920    pub task_revision: HarnessRevision,
921    pub policy_digest: HarnessRequestDigest,
922    pub plans: Vec<HarnessOrdinaryLaunchPlanOptionV1>,
923    pub managed_worktree_profiles: Vec<HarnessManagedWorktreeProfileOptionV1>,
924    pub context_sources: Vec<HarnessContextSourceSelectionV1>,
925    pub delivery_bundles: Vec<HarnessDeliveryBundleSelectionV1>,
926    pub current_issued_spec: Option<HarnessIssuedExecutionSpecSummaryV1>,
927    pub truncated: bool,
928    /// Resumes specifically the `plans` list's own page (mirrors
929    /// `HarnessLaunchPlanPageV1::next_plan_id`) -- `Some` only when `plans`
930    /// itself was cut for paging, `None` whenever that list's page is
931    /// complete even if `truncated` is `true` for one of the other three
932    /// lists. `#[serde(default)]`: additive over the original response
933    /// shape, an older caller that never asked for it still deserializes.
934    #[serde(default)]
935    pub next_after: Option<HarnessSelectorV1>,
936    /// Every early exit `context_source_option` took while building
937    /// `context_sources` above, one entry per excluded run --
938    /// instrumentation only, see [`ContextSourceExclusionV1`]. Bounded
939    /// independently of the other four lists at
940    /// [`HARNESS_CONTEXT_SOURCE_EXCLUSIONS_MAX`]: their own
941    /// `HARNESS_TASK_LAUNCH_OPTIONS_MAX` sizes an operator-facing selection
942    /// page, this one sizes a diagnostic dump. `#[serde(default)]`:
943    /// additive over the pre-instrumentation wire shape, an older caller
944    /// that never asked for it still deserializes.
945    #[serde(default)]
946    pub context_source_exclusions: Vec<ContextSourceExclusionEntryV1>,
947}
948
949impl HarnessTaskLaunchOptionsV1 {
950    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
951        self.task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
952        self.task_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
953        self.policy_digest.validate().map_err(HarnessOperatorApiError::Protocol)?;
954        if self.plans.len() > HARNESS_TASK_LAUNCH_OPTIONS_MAX
955            || self.managed_worktree_profiles.len() > HARNESS_TASK_LAUNCH_OPTIONS_MAX
956            || self.context_sources.len() > HARNESS_TASK_LAUNCH_OPTIONS_MAX
957            || self.delivery_bundles.len() > HARNESS_TASK_LAUNCH_OPTIONS_MAX
958            || self.context_source_exclusions.len() > HARNESS_CONTEXT_SOURCE_EXCLUSIONS_MAX
959        {
960            return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
961        }
962        for plan in &self.plans { plan.validate()?; }
963        for profile in &self.managed_worktree_profiles { profile.validate()?; }
964        for source in &self.context_sources {
965            source.validate().map_err(HarnessOperatorApiError::Protocol)?;
966        }
967        for delivery in &self.delivery_bundles {
968            delivery.validate().map_err(HarnessOperatorApiError::Protocol)?;
969        }
970        for exclusion in &self.context_source_exclusions {
971            exclusion.validate()?;
972        }
973        if self.context_source_exclusions.windows(2).any(|items| {
974            items[0].run_id >= items[1].run_id
975        }) {
976            return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
977        }
978        if self.plans.windows(2).any(|items| {
979            (&items[0].plan.plan_id, items[0].plan.revision)
980                >= (&items[1].plan.plan_id, items[1].plan.revision)
981        }) || self.managed_worktree_profiles.windows(2).any(|items| {
982            (
983                &items[0].node_id,
984                &items[0].source_workspace_id,
985                &items[0].profile_id,
986                &items[0].profile_revision,
987            ) >= (
988                &items[1].node_id,
989                &items[1].source_workspace_id,
990                &items[1].profile_id,
991                &items[1].profile_revision,
992            )
993        }) || self.context_sources.windows(2).any(|items| {
994            (&items[0].source_run_id, items[0].source_run_revision)
995                >= (&items[1].source_run_id, items[1].source_run_revision)
996        }) || self.delivery_bundles.windows(2).any(|items| {
997            (&items[0].bundle.bundle_id, &items[0].bundle.revision)
998                >= (&items[1].bundle.bundle_id, &items[1].bundle.revision)
999        }) {
1000            return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
1001        }
1002        if let Some(current) = &self.current_issued_spec {
1003            current.validate()?;
1004            if current.task_id != self.task_id {
1005                return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
1006            }
1007        }
1008        if let Some(next_after) = &self.next_after {
1009            next_after.validate().map_err(HarnessOperatorApiError::Protocol)?;
1010            if !self.truncated
1011                || self.plans.last().map(|plan| &plan.plan.plan_id) != Some(next_after)
1012            {
1013                return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
1014            }
1015        }
1016        Ok(())
1017    }
1018
1019    pub fn validate_for(&self, task_id: &HarnessTaskId) -> Result<(), HarnessOperatorApiError> {
1020        self.validate()?;
1021        if &self.task_id != task_id {
1022            return Err(HarnessOperatorApiError::InvalidTaskLaunchOptions);
1023        }
1024        Ok(())
1025    }
1026}
1027
1028#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1029#[serde(deny_unknown_fields)]
1030pub struct HarnessReplaceTaskExecutionSpecRequestV2 {
1031    pub authority: HarnessOperatorAuthorityV1,
1032    pub task_id: HarnessTaskId,
1033    pub expected_task_revision: HarnessRevision,
1034    pub expected_execution_spec_revision: HarnessExpectedExecutionSpecRevisionV1,
1035    pub selection: HarnessReviewedTaskLaunchSelectionV1,
1036}
1037
1038impl HarnessReplaceTaskExecutionSpecRequestV2 {
1039    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1040        self.authority.validate().map_err(HarnessOperatorApiError::Protocol)?;
1041        self.task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
1042        self.expected_task_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
1043        self.expected_execution_spec_revision.validate()
1044            .map_err(HarnessOperatorApiError::Protocol)?;
1045        self.selection.validate()
1046    }
1047}
1048
1049#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1050#[serde(deny_unknown_fields)]
1051pub struct HarnessStartTaskRequestV2 {
1052    pub authority: HarnessOperatorAuthorityV1,
1053    pub task_id: HarnessTaskId,
1054    pub expected_task_revision: HarnessRevision,
1055    pub expected_execution_spec_revision: HarnessRevision,
1056    pub expected_launch_issuance: HarnessTaskLaunchIssuanceRefV1,
1057}
1058
1059impl HarnessStartTaskRequestV2 {
1060    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1061        self.authority.validate().map_err(HarnessOperatorApiError::Protocol)?;
1062        self.task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
1063        self.expected_task_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
1064        self.expected_execution_spec_revision.validate()
1065            .map_err(HarnessOperatorApiError::Protocol)?;
1066        self.expected_launch_issuance.validate().map_err(HarnessOperatorApiError::Protocol)?;
1067        if self.expected_launch_issuance.revision != self.expected_execution_spec_revision {
1068            return Err(HarnessOperatorApiError::InvalidTaskLaunchSelection);
1069        }
1070        Ok(())
1071    }
1072}
1073
1074#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1075#[serde(transparent)]
1076pub struct HarnessRepositoryPathV1(String);
1077
1078impl HarnessRepositoryPathV1 {
1079    pub fn new(value: impl Into<String>) -> Result<Self, HarnessOperatorApiError> {
1080        let value = value.into();
1081        validate_repository_path(&value)?;
1082        Ok(Self(value))
1083    }
1084
1085    pub fn as_str(&self) -> &str { &self.0 }
1086
1087    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1088        validate_repository_path(&self.0)
1089    }
1090}
1091
1092impl<'de> Deserialize<'de> for HarnessRepositoryPathV1 {
1093    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1094    where D: Deserializer<'de> {
1095        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
1096    }
1097}
1098
1099/// A bounded, UTF-8 host filesystem path: a workspace/worktree root, a
1100/// worktree target, or a host-directory-browse cursor (`directory`/`after`/
1101/// `parent`/`next_after`). Mirrors the node's own `OpaqueHostPath` narrowed
1102/// to its UTF-8 representation only, the same way `HarnessRepositoryPathV1`
1103/// narrows the node's dual-representation `RepositoryPath` -- see
1104/// `repository_path_from_api`/`project_repository_path` in
1105/// `hatchery-harness-service` for that precedent: a genuinely non-UTF-8
1106/// host path from the node is a projection failure, never silently
1107/// lossy-displayed. Validation intentionally stays as minimal as the node's
1108/// own `validate_opaque_host_path` (non-empty, bounded, no NUL byte): unlike
1109/// a repository-relative path, an absolute host path legitimately contains
1110/// backslashes, drive letters, and colons (Windows), so this type imposes no
1111/// format assumptions beyond that.
1112#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1113#[serde(transparent)]
1114pub struct HarnessHostPathV1(String);
1115
1116impl HarnessHostPathV1 {
1117    pub fn new(value: impl Into<String>) -> Result<Self, HarnessOperatorApiError> {
1118        let value = value.into();
1119        validate_host_path(&value)?;
1120        Ok(Self(value))
1121    }
1122
1123    pub fn as_str(&self) -> &str { &self.0 }
1124
1125    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1126        validate_host_path(&self.0)
1127    }
1128}
1129
1130impl<'de> Deserialize<'de> for HarnessHostPathV1 {
1131    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1132    where D: Deserializer<'de> {
1133        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
1134    }
1135}
1136
1137fn validate_host_path(value: &str) -> Result<(), HarnessOperatorApiError> {
1138    if value.is_empty() || value.len() > HARNESS_HOST_PATH_MAX_BYTES || value.contains('\0') {
1139        return Err(HarnessOperatorApiError::InvalidHostPath);
1140    }
1141    Ok(())
1142}
1143
1144#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1145#[serde(transparent)]
1146pub struct HarnessGitObjectIdV1(String);
1147
1148impl HarnessGitObjectIdV1 {
1149    pub fn new(value: impl Into<String>) -> Result<Self, HarnessOperatorApiError> {
1150        let value = value.into();
1151        validate_git_object_id(&value)?;
1152        Ok(Self(value))
1153    }
1154
1155    pub fn as_str(&self) -> &str { &self.0 }
1156
1157    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1158        validate_git_object_id(&self.0)
1159    }
1160}
1161
1162impl<'de> Deserialize<'de> for HarnessGitObjectIdV1 {
1163    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1164    where D: Deserializer<'de> {
1165        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
1166    }
1167}
1168
1169#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1170#[serde(transparent)]
1171pub struct HarnessWorkspaceFileRevisionV1(String);
1172
1173impl HarnessWorkspaceFileRevisionV1 {
1174    pub fn new(value: impl Into<String>) -> Result<Self, HarnessOperatorApiError> {
1175        let value = value.into();
1176        if value.len() != 64 || !value.bytes().all(is_lower_hex) {
1177            return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1178        }
1179        Ok(Self(value))
1180    }
1181
1182    pub fn as_str(&self) -> &str { &self.0 }
1183
1184    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1185        if self.0.len() != 64 || !self.0.bytes().all(is_lower_hex) {
1186            return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1187        }
1188        Ok(())
1189    }
1190}
1191
1192impl<'de> Deserialize<'de> for HarnessWorkspaceFileRevisionV1 {
1193    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1194    where D: Deserializer<'de> {
1195        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
1196    }
1197}
1198
1199#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1200#[serde(deny_unknown_fields)]
1201pub struct HarnessRunWorkspaceOriginV1 {
1202    pub run_id: HarnessRunId,
1203    pub run_revision: HarnessRevision,
1204    pub node_id: HarnessSelectorV1,
1205    pub node_incarnation_id: HarnessNodeIncarnationV1,
1206    pub workspace_id: HarnessSelectorV1,
1207}
1208
1209impl HarnessRunWorkspaceOriginV1 {
1210    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1211        self.run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
1212        self.run_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
1213        self.node_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
1214        self.node_incarnation_id.validate()?;
1215        self.workspace_id.validate().map_err(HarnessOperatorApiError::Protocol)
1216    }
1217
1218    pub fn validate_for(&self, run_id: &HarnessRunId) -> Result<(), HarnessOperatorApiError> {
1219        self.validate()?;
1220        if &self.run_id != run_id {
1221            return Err(HarnessOperatorApiError::InvalidWorkspaceOrigin);
1222        }
1223        Ok(())
1224    }
1225}
1226
1227/// Node-scoped sibling of `HarnessRunWorkspaceOriginV1`: identifies a
1228/// workspace read routed straight from a node/workspace pair, with no run
1229/// binding to descend from.
1230#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1231#[serde(deny_unknown_fields)]
1232pub struct HarnessNodeWorkspaceOriginV1 {
1233    pub node_id: String,
1234    pub node_incarnation_id: String,
1235    pub workspace_id: String,
1236}
1237
1238impl HarnessNodeWorkspaceOriginV1 {
1239    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1240        if !valid_runtime_id(&self.node_id, 128)
1241            || self.node_incarnation_id.len() != 32
1242            || !self.node_incarnation_id.bytes().all(is_lower_hex)
1243            || !valid_runtime_id(&self.workspace_id, 128)
1244        {
1245            return Err(HarnessOperatorApiError::InvalidWorkspaceOrigin);
1246        }
1247        Ok(())
1248    }
1249
1250    pub fn validate_for(
1251        &self,
1252        node_id: &str,
1253        workspace_id: &str,
1254    ) -> Result<(), HarnessOperatorApiError> {
1255        self.validate()?;
1256        if self.node_id != node_id || self.workspace_id != workspace_id {
1257            return Err(HarnessOperatorApiError::InvalidWorkspaceOrigin);
1258        }
1259        Ok(())
1260    }
1261}
1262
1263#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1264#[serde(rename_all = "kebab-case")]
1265pub enum HarnessWorkspaceEntryKindV1 {
1266    File,
1267    Directory,
1268}
1269
1270#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1271#[serde(deny_unknown_fields)]
1272pub struct HarnessWorkspaceTreeEntryV1 {
1273    pub relative_path: HarnessRepositoryPathV1,
1274    pub kind: HarnessWorkspaceEntryKindV1,
1275}
1276
1277impl HarnessWorkspaceTreeEntryV1 {
1278    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1279        self.relative_path.validate()
1280    }
1281}
1282
1283#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1284#[serde(rename_all = "kebab-case")]
1285pub enum HarnessGitStatusCodeV1 {
1286    Unmodified,
1287    Added,
1288    Modified,
1289    Deleted,
1290    Renamed,
1291    Copied,
1292    Unmerged,
1293    Untracked,
1294    Ignored,
1295    TypeChanged,
1296}
1297
1298#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1299#[serde(deny_unknown_fields)]
1300pub struct HarnessGitStatusEntryV1 {
1301    pub index_status: HarnessGitStatusCodeV1,
1302    pub worktree_status: HarnessGitStatusCodeV1,
1303    pub path: HarnessRepositoryPathV1,
1304    pub previous_path: Option<HarnessRepositoryPathV1>,
1305}
1306
1307impl HarnessGitStatusEntryV1 {
1308    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1309        self.path.validate()?;
1310        if let Some(previous_path) = &self.previous_path {
1311            previous_path.validate()?;
1312            if previous_path == &self.path {
1313                return Err(HarnessOperatorApiError::InvalidGitStatus);
1314            }
1315        }
1316        let renamed_or_copied = matches!(self.index_status, HarnessGitStatusCodeV1::Renamed | HarnessGitStatusCodeV1::Copied)
1317            || matches!(self.worktree_status, HarnessGitStatusCodeV1::Renamed | HarnessGitStatusCodeV1::Copied);
1318        if self.previous_path.is_some() != renamed_or_copied {
1319            return Err(HarnessOperatorApiError::InvalidGitStatus);
1320        }
1321        Ok(())
1322    }
1323}
1324
1325#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1326#[serde(deny_unknown_fields)]
1327pub struct HarnessGitCommitSummaryV1 {
1328    pub id: HarnessGitObjectIdV1,
1329    pub summary: String,
1330}
1331
1332impl HarnessGitCommitSummaryV1 {
1333    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1334        self.id.validate()?;
1335        validate_git_single_line(&self.summary, HARNESS_GIT_SUMMARY_MAX_BYTES, false)
1336    }
1337}
1338
1339#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1340#[serde(deny_unknown_fields)]
1341pub struct HarnessGitSummaryV1 {
1342    pub is_repository: bool,
1343    pub branch: Option<String>,
1344    pub status: Vec<HarnessGitStatusEntryV1>,
1345    pub recent_commits: Vec<HarnessGitCommitSummaryV1>,
1346    pub truncated: bool,
1347}
1348
1349impl HarnessGitSummaryV1 {
1350    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1351        if self.status.len() > HARNESS_GIT_STATUS_ENTRIES_MAX
1352            || self.recent_commits.len() > HARNESS_GIT_RECENT_COMMITS_MAX
1353            || !self.is_repository && (self.branch.is_some() || !self.status.is_empty() || !self.recent_commits.is_empty() || self.truncated)
1354        {
1355            return Err(HarnessOperatorApiError::InvalidGitSummary);
1356        }
1357        if let Some(branch) = &self.branch {
1358            validate_git_single_line(branch, HARNESS_REPOSITORY_PATH_MAX_BYTES, true)?;
1359        }
1360        for status in &self.status { status.validate()?; }
1361        if self.status.windows(2).any(|entries| entries[0].path >= entries[1].path) {
1362            return Err(HarnessOperatorApiError::InvalidGitStatus);
1363        }
1364        for commit in &self.recent_commits { commit.validate()?; }
1365        if self.recent_commits.iter().enumerate().any(|(index, commit)| {
1366            self.recent_commits[..index].iter().any(|existing| existing.id == commit.id)
1367        }) {
1368            return Err(HarnessOperatorApiError::InvalidGitSummary);
1369        }
1370        Ok(())
1371    }
1372}
1373
1374/// Additive mirror of `gate4agent_node_protocol::WorkspaceInspectionTruncationV1`
1375/// carried onto both `HarnessRunWorkspaceInspectionV1` and
1376/// `HarnessNodeWorkspaceInspectionV1` — see that type for field meaning.
1377/// Kept as this crate's own type (rather than reused directly) to match
1378/// every other leaf field on these two structs, which project into
1379/// `Harness*V1` types rather than exposing node-protocol/c2-protocol types
1380/// on the operator-facing API surface.
1381#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1382#[serde(deny_unknown_fields)]
1383pub struct HarnessWorkspaceInspectionTruncationV1 {
1384    pub walk_time_budget_exceeded: bool,
1385    pub walk_entry_cap_exceeded: bool,
1386    pub git_time_budget_exceeded: bool,
1387    pub entries_visited: u64,
1388    pub elapsed_ms: u64,
1389}
1390
1391#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1392#[serde(deny_unknown_fields)]
1393pub struct HarnessRunWorkspaceInspectionV1 {
1394    pub origin: HarnessRunWorkspaceOriginV1,
1395    pub entries: Vec<HarnessWorkspaceTreeEntryV1>,
1396    pub tree_truncated: bool,
1397    pub git: HarnessGitSummaryV1,
1398    #[serde(default, skip_serializing_if = "Option::is_none")]
1399    pub truncation: Option<HarnessWorkspaceInspectionTruncationV1>,
1400}
1401
1402impl HarnessRunWorkspaceInspectionV1 {
1403    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1404        self.origin.validate()?;
1405        if self.entries.len() > HARNESS_WORKSPACE_TREE_ENTRIES_MAX {
1406            return Err(HarnessOperatorApiError::InvalidWorkspaceTree);
1407        }
1408        for entry in &self.entries { entry.validate()?; }
1409        if self.entries.windows(2).any(|entries| entries[0].relative_path >= entries[1].relative_path) {
1410            return Err(HarnessOperatorApiError::InvalidWorkspaceTree);
1411        }
1412        self.git.validate()
1413    }
1414
1415    pub fn validate_for(&self, run_id: &HarnessRunId) -> Result<(), HarnessOperatorApiError> {
1416        self.validate()?;
1417        self.origin.validate_for(run_id)
1418    }
1419}
1420
1421/// Node-scoped sibling of `HarnessRunWorkspaceInspectionV1`.
1422#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1423#[serde(deny_unknown_fields)]
1424pub struct HarnessNodeWorkspaceInspectionV1 {
1425    pub origin: HarnessNodeWorkspaceOriginV1,
1426    pub entries: Vec<HarnessWorkspaceTreeEntryV1>,
1427    pub tree_truncated: bool,
1428    pub git: HarnessGitSummaryV1,
1429    #[serde(default, skip_serializing_if = "Option::is_none")]
1430    pub truncation: Option<HarnessWorkspaceInspectionTruncationV1>,
1431}
1432
1433impl HarnessNodeWorkspaceInspectionV1 {
1434    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1435        self.origin.validate()?;
1436        if self.entries.len() > HARNESS_WORKSPACE_TREE_ENTRIES_MAX {
1437            return Err(HarnessOperatorApiError::InvalidWorkspaceTree);
1438        }
1439        for entry in &self.entries { entry.validate()?; }
1440        if self.entries.windows(2).any(|entries| entries[0].relative_path >= entries[1].relative_path) {
1441            return Err(HarnessOperatorApiError::InvalidWorkspaceTree);
1442        }
1443        self.git.validate()
1444    }
1445
1446    pub fn validate_for(
1447        &self,
1448        node_id: &str,
1449        workspace_id: &str,
1450    ) -> Result<(), HarnessOperatorApiError> {
1451        self.validate()?;
1452        self.origin.validate_for(node_id, workspace_id)
1453    }
1454}
1455
1456#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1457#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
1458pub enum HarnessWorkspaceFileContentV1 {
1459    Utf8 { text: String, byte_len: u32 },
1460    NonUtf8 { byte_len: u32 },
1461    TooLarge { limit_bytes: u32 },
1462}
1463
1464impl HarnessWorkspaceFileContentV1 {
1465    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1466        match self {
1467            Self::Utf8 { text, byte_len } => {
1468                if text.len() > HARNESS_WORKSPACE_FILE_MAX_BYTES || usize::try_from(*byte_len).ok() != Some(text.len()) {
1469                    return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1470                }
1471            }
1472            Self::NonUtf8 { byte_len } => {
1473                if usize::try_from(*byte_len).map_or(true, |length| length > HARNESS_WORKSPACE_FILE_MAX_BYTES) {
1474                    return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1475                }
1476            }
1477            Self::TooLarge { limit_bytes } => {
1478                if usize::try_from(*limit_bytes).ok() != Some(HARNESS_WORKSPACE_FILE_MAX_BYTES) {
1479                    return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1480                }
1481            }
1482        }
1483        Ok(())
1484    }
1485}
1486
1487#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1488#[serde(deny_unknown_fields)]
1489pub struct HarnessRunWorkspaceFileV1 {
1490    pub origin: HarnessRunWorkspaceOriginV1,
1491    pub path: HarnessRepositoryPathV1,
1492    pub content: HarnessWorkspaceFileContentV1,
1493    pub revision: Option<HarnessWorkspaceFileRevisionV1>,
1494}
1495
1496impl HarnessRunWorkspaceFileV1 {
1497    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1498        self.origin.validate()?;
1499        self.path.validate()?;
1500        self.content.validate()?;
1501        if let Some(revision) = &self.revision { revision.validate()?; }
1502        if !matches!(&self.content, HarnessWorkspaceFileContentV1::Utf8 { .. }) && self.revision.is_some() {
1503            return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1504        }
1505        Ok(())
1506    }
1507
1508
1509    pub fn validate_for(
1510        &self,
1511        run_id: &HarnessRunId,
1512        path: &HarnessRepositoryPathV1,
1513    ) -> Result<(), HarnessOperatorApiError> {
1514        self.validate()?;
1515        self.origin.validate_for(run_id)?;
1516        if &self.path != path {
1517            return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1518        }
1519        Ok(())
1520    }
1521}
1522
1523/// Node-scoped sibling of `HarnessRunWorkspaceFileV1`.
1524#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1525#[serde(deny_unknown_fields)]
1526pub struct HarnessNodeWorkspaceFileV1 {
1527    pub origin: HarnessNodeWorkspaceOriginV1,
1528    pub path: HarnessRepositoryPathV1,
1529    pub content: HarnessWorkspaceFileContentV1,
1530    pub revision: Option<HarnessWorkspaceFileRevisionV1>,
1531}
1532
1533impl HarnessNodeWorkspaceFileV1 {
1534    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1535        self.origin.validate()?;
1536        self.path.validate()?;
1537        self.content.validate()?;
1538        if let Some(revision) = &self.revision { revision.validate()?; }
1539        if !matches!(&self.content, HarnessWorkspaceFileContentV1::Utf8 { .. }) && self.revision.is_some() {
1540            return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1541        }
1542        Ok(())
1543    }
1544
1545    pub fn validate_for(
1546        &self,
1547        node_id: &str,
1548        workspace_id: &str,
1549        path: &HarnessRepositoryPathV1,
1550    ) -> Result<(), HarnessOperatorApiError> {
1551        self.validate()?;
1552        self.origin.validate_for(node_id, workspace_id)?;
1553        if &self.path != path {
1554            return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
1555        }
1556        Ok(())
1557    }
1558}
1559
1560/// Response payload for `CreateNodeWorkspaceDirectory`: the one node-scoped
1561/// creation response (`WorkspaceDirectoryCreated`) that carries a
1562/// `WorkspaceEntry` instead of a `WorkspaceFileRead` -- `HarnessNodeWorkspace
1563/// FileV1` above covers `WriteNodeWorkspaceFile`/`CreateNodeWorkspaceFile`,
1564/// both of which echo a file. Reuses `HarnessWorkspaceTreeEntryV1`, already
1565/// the entry shape inside `HarnessNodeWorkspaceInspectionV1`.
1566#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1567#[serde(deny_unknown_fields)]
1568pub struct HarnessNodeWorkspaceDirectoryV1 {
1569    pub origin: HarnessNodeWorkspaceOriginV1,
1570    pub entry: HarnessWorkspaceTreeEntryV1,
1571}
1572
1573impl HarnessNodeWorkspaceDirectoryV1 {
1574    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1575        self.origin.validate()?;
1576        self.entry.validate()?;
1577        if self.entry.kind != HarnessWorkspaceEntryKindV1::Directory {
1578            return Err(HarnessOperatorApiError::InvalidWorkspaceTree);
1579        }
1580        Ok(())
1581    }
1582
1583    pub fn validate_for(
1584        &self,
1585        node_id: &str,
1586        workspace_id: &str,
1587        path: &HarnessRepositoryPathV1,
1588    ) -> Result<(), HarnessOperatorApiError> {
1589        self.validate()?;
1590        self.origin.validate_for(node_id, workspace_id)?;
1591        if &self.entry.relative_path != path {
1592            return Err(HarnessOperatorApiError::InvalidWorkspaceTree);
1593        }
1594        Ok(())
1595    }
1596}
1597
1598/// One entry in a `BrowseHostDirectories` page. Mirrors the node's own
1599/// `HostDirectoryEntry` field-for-field; that type's own constructor already
1600/// rejects a non-UTF-8 `path`, so the node never actually produces the case
1601/// `HarnessHostPathV1` would reject here.
1602#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1603#[serde(deny_unknown_fields)]
1604pub struct HarnessHostDirectoryEntryV1 {
1605    pub path: HarnessHostPathV1,
1606    pub display_name: String,
1607    pub is_link: bool,
1608}
1609
1610impl HarnessHostDirectoryEntryV1 {
1611    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1612        self.path.validate()?;
1613        if self.display_name.is_empty()
1614            || self.display_name.len() > HARNESS_HOST_DIRECTORY_DISPLAY_NAME_MAX_BYTES
1615            || self.display_name.chars().any(char::is_control)
1616        {
1617            return Err(HarnessOperatorApiError::InvalidHostDirectoryBrowseRequest);
1618        }
1619        Ok(())
1620    }
1621}
1622
1623/// `BrowseHostDirectories`'s reply: mirrors the node's own
1624/// `HostDirectoryListing` field-for-field. Entries are deliberately not
1625/// asserted to be sorted (unlike most other bounded collections on this
1626/// wire, e.g. `HarnessWorkspaceTreeEntryV1`): a live directory listing has no
1627/// such guarantee from the underlying filesystem, so imposing one here would
1628/// reject a legitimate page.
1629#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1630#[serde(deny_unknown_fields)]
1631pub struct HarnessHostDirectoryListingV1 {
1632    pub directory: Option<HarnessHostPathV1>,
1633    pub parent: Option<HarnessHostPathV1>,
1634    pub entries: Vec<HarnessHostDirectoryEntryV1>,
1635    pub next_after: Option<HarnessHostPathV1>,
1636    /// True only when another page of supported directory entries is
1637    /// available.
1638    pub incomplete: bool,
1639}
1640
1641impl HarnessHostDirectoryListingV1 {
1642    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1643        if let Some(directory) = &self.directory { directory.validate()?; }
1644        if let Some(parent) = &self.parent { parent.validate()?; }
1645        if self.entries.len() > HARNESS_HOST_DIRECTORY_ENTRIES_MAX {
1646            return Err(HarnessOperatorApiError::InvalidHostDirectoryBrowseRequest);
1647        }
1648        for entry in &self.entries { entry.validate()?; }
1649        if let Some(next_after) = &self.next_after { next_after.validate()?; }
1650        Ok(())
1651    }
1652}
1653
1654/// Mirrors the node's own `WorktreeServiceMode`.
1655#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1656#[serde(rename_all = "kebab-case")]
1657pub enum HarnessWorktreeServiceModeV1 {
1658    Manual,
1659    Managed,
1660    Off,
1661}
1662
1663/// `RegisterWorkspace`/`CreateStandaloneWorkspace`'s reply, and half of
1664/// `CreateWorktree`'s. A deliberately narrowed projection of the node's own
1665/// `WorkspaceSnapshot`: `sessions` is dropped (redundant with the runtime
1666/// inventory, which every roster-affecting resource mutation already
1667/// invalidates on success -- see `HarnessOperatorRequestV1::RegisterWorkspace`'s
1668/// doc comment) and `managed_worktree_profiles` is dropped (`SpawnManagedWorktree`
1669/// has no typed operator verb -- see that request variant's own doc comment
1670/// for why -- so a profile-catalog listing here would serve no reachable
1671/// caller). `workspace_id` stays a bounded opaque string rather than a typed
1672/// selector, matching every other node-scoped workspace id on this wire
1673/// (`HarnessNodeWorkspaceOriginV1.workspace_id`, etc).
1674#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1675#[serde(deny_unknown_fields)]
1676pub struct HarnessWorkspaceSnapshotV1 {
1677    pub workspace_id: String,
1678    pub canonical_root: HarnessHostPathV1,
1679    #[serde(default, skip_serializing_if = "Option::is_none")]
1680    pub worktree_service_mode: Option<HarnessWorktreeServiceModeV1>,
1681}
1682
1683impl HarnessWorkspaceSnapshotV1 {
1684    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1685        if !valid_runtime_id(&self.workspace_id, 128) {
1686            return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
1687        }
1688        self.canonical_root.validate()
1689    }
1690}
1691
1692/// The other half of `CreateWorktree`'s reply. Mirrors the node's own
1693/// `GitWorktreeSnapshot`, minus `lock_reason`/`prunable_reason` (free-text
1694/// reason strings the node's own C2-facing mirror, `C2GitWorktreeSnapshot`,
1695/// already drops -- this wire never receives them to begin with).
1696#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1697#[serde(deny_unknown_fields)]
1698pub struct HarnessGitWorktreeSnapshotV1 {
1699    pub path: HarnessHostPathV1,
1700    pub head: String,
1701    pub branch: Option<String>,
1702    pub is_bare: bool,
1703    pub is_main: bool,
1704    pub locked: bool,
1705    pub prunable: bool,
1706    pub workspace_id: Option<String>,
1707}
1708
1709impl HarnessGitWorktreeSnapshotV1 {
1710    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1711        self.path.validate()?;
1712        if !valid_native_single_line(&self.head, HARNESS_GIT_SUMMARY_MAX_BYTES, true) {
1713            return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
1714        }
1715        if let Some(branch) = &self.branch {
1716            if !valid_native_single_line(branch, HARNESS_REPOSITORY_PATH_MAX_BYTES, false) {
1717                return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
1718            }
1719        }
1720        if let Some(workspace_id) = &self.workspace_id {
1721            if !valid_runtime_id(workspace_id, 128) {
1722                return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
1723            }
1724        }
1725        Ok(())
1726    }
1727}
1728
1729#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1730#[serde(rename_all = "kebab-case")]
1731pub enum HarnessGitSignatureStatusV1 {
1732    Good,
1733    Bad,
1734    UnknownValidity,
1735    ExpiredSignature,
1736    ExpiredKey,
1737    RevokedKey,
1738    CannotCheck,
1739    NoSignature,
1740}
1741
1742#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1743#[serde(deny_unknown_fields)]
1744pub struct HarnessGitCommitV1 {
1745    pub id: HarnessGitObjectIdV1,
1746    pub parents: Vec<HarnessGitObjectIdV1>,
1747    pub subject: String,
1748    pub author_name: String,
1749    pub authored_at: String,
1750    pub committer_name: String,
1751    pub committed_at: String,
1752    pub signature_status: HarnessGitSignatureStatusV1,
1753    pub signer: Option<String>,
1754}
1755
1756impl HarnessGitCommitV1 {
1757    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1758        self.id.validate()?;
1759        if self.parents.len() > HARNESS_GIT_COMMIT_PARENTS_MAX {
1760            return Err(HarnessOperatorApiError::InvalidGitHistory);
1761        }
1762        for parent in &self.parents { parent.validate()?; }
1763        if self.parents.iter().any(|parent| parent == &self.id) {
1764            return Err(HarnessOperatorApiError::InvalidGitHistory);
1765        }
1766        validate_git_single_line(&self.subject, HARNESS_GIT_SUMMARY_MAX_BYTES, false)?;
1767        validate_git_single_line(&self.author_name, HARNESS_GIT_IDENTITY_MAX_BYTES, false)?;
1768        validate_git_single_line(&self.authored_at, HARNESS_GIT_TIMESTAMP_MAX_BYTES, true)?;
1769        validate_git_single_line(&self.committer_name, HARNESS_GIT_IDENTITY_MAX_BYTES, false)?;
1770        validate_git_single_line(&self.committed_at, HARNESS_GIT_TIMESTAMP_MAX_BYTES, true)?;
1771        if let Some(signer) = &self.signer {
1772            validate_git_single_line(signer, HARNESS_GIT_SIGNER_MAX_BYTES, true)?;
1773        }
1774        Ok(())
1775    }
1776}
1777
1778#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1779#[serde(deny_unknown_fields)]
1780pub struct HarnessRunGitHistoryPageV1 {
1781    pub origin: HarnessRunWorkspaceOriginV1,
1782    pub path: Option<HarnessRepositoryPathV1>,
1783    pub commits: Vec<HarnessGitCommitV1>,
1784    pub next_before: Option<HarnessGitObjectIdV1>,
1785    pub truncated: bool,
1786}
1787
1788impl HarnessRunGitHistoryPageV1 {
1789    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1790        self.origin.validate()?;
1791        if let Some(path) = &self.path { path.validate()?; }
1792        if self.commits.len() > usize::from(HARNESS_GIT_HISTORY_LIMIT_MAX) {
1793            return Err(HarnessOperatorApiError::InvalidGitHistory);
1794        }
1795        for commit in &self.commits { commit.validate()?; }
1796        if self.commits.iter().enumerate().any(|(index, commit)| {
1797            self.commits[..index].iter().any(|existing| existing.id == commit.id)
1798        }) {
1799            return Err(HarnessOperatorApiError::InvalidGitHistory);
1800        }
1801        if let Some(next_before) = &self.next_before {
1802            next_before.validate()?;
1803            if self.commits.last().map(|commit| &commit.id) != Some(next_before) {
1804                return Err(HarnessOperatorApiError::InvalidGitHistory);
1805            }
1806        }
1807        Ok(())
1808    }
1809
1810
1811    pub fn validate_for(
1812        &self,
1813        run_id: &HarnessRunId,
1814        path: Option<&HarnessRepositoryPathV1>,
1815        limit: u16,
1816    ) -> Result<(), HarnessOperatorApiError> {
1817        self.validate()?;
1818        self.origin.validate_for(run_id)?;
1819        if self.path.as_ref() != path || self.commits.len() > usize::from(limit) {
1820            return Err(HarnessOperatorApiError::InvalidGitHistory);
1821        }
1822        Ok(())
1823    }
1824}
1825
1826/// Node-scoped sibling of `HarnessRunGitHistoryPageV1`.
1827#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1828#[serde(deny_unknown_fields)]
1829pub struct HarnessNodeGitHistoryPageV1 {
1830    pub origin: HarnessNodeWorkspaceOriginV1,
1831    pub path: Option<HarnessRepositoryPathV1>,
1832    pub commits: Vec<HarnessGitCommitV1>,
1833    pub next_before: Option<HarnessGitObjectIdV1>,
1834    pub truncated: bool,
1835}
1836
1837impl HarnessNodeGitHistoryPageV1 {
1838    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1839        self.origin.validate()?;
1840        if let Some(path) = &self.path { path.validate()?; }
1841        if self.commits.len() > usize::from(HARNESS_GIT_HISTORY_LIMIT_MAX) {
1842            return Err(HarnessOperatorApiError::InvalidGitHistory);
1843        }
1844        for commit in &self.commits { commit.validate()?; }
1845        if self.commits.iter().enumerate().any(|(index, commit)| {
1846            self.commits[..index].iter().any(|existing| existing.id == commit.id)
1847        }) {
1848            return Err(HarnessOperatorApiError::InvalidGitHistory);
1849        }
1850        if let Some(next_before) = &self.next_before {
1851            next_before.validate()?;
1852            if self.commits.last().map(|commit| &commit.id) != Some(next_before) {
1853                return Err(HarnessOperatorApiError::InvalidGitHistory);
1854            }
1855        }
1856        Ok(())
1857    }
1858
1859    pub fn validate_for(
1860        &self,
1861        node_id: &str,
1862        workspace_id: &str,
1863        path: Option<&HarnessRepositoryPathV1>,
1864        limit: u16,
1865    ) -> Result<(), HarnessOperatorApiError> {
1866        self.validate()?;
1867        self.origin.validate_for(node_id, workspace_id)?;
1868        if self.path.as_ref() != path || self.commits.len() > usize::from(limit) {
1869            return Err(HarnessOperatorApiError::InvalidGitHistory);
1870        }
1871        Ok(())
1872    }
1873}
1874
1875#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1876#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
1877pub enum HarnessGitDiffModeV1 {
1878    Working,
1879    Staged,
1880    Commit { revision: HarnessGitObjectIdV1 },
1881}
1882
1883impl HarnessGitDiffModeV1 {
1884    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1885        match self {
1886            Self::Working | Self::Staged => Ok(()),
1887            Self::Commit { revision } => revision.validate(),
1888        }
1889    }
1890}
1891
1892#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1893#[serde(deny_unknown_fields)]
1894pub struct HarnessRunGitDiffV1 {
1895    pub origin: HarnessRunWorkspaceOriginV1,
1896    pub mode: HarnessGitDiffModeV1,
1897    pub path: Option<HarnessRepositoryPathV1>,
1898    pub text: String,
1899    pub truncated: bool,
1900}
1901
1902impl HarnessRunGitDiffV1 {
1903    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1904        self.origin.validate()?;
1905        self.mode.validate()?;
1906        if let Some(path) = &self.path { path.validate()?; }
1907        if self.text.len() > HARNESS_GIT_DIFF_MAX_BYTES {
1908            return Err(HarnessOperatorApiError::InvalidGitDiff);
1909        }
1910        Ok(())
1911    }
1912
1913
1914    pub fn validate_for(
1915        &self,
1916        run_id: &HarnessRunId,
1917        mode: &HarnessGitDiffModeV1,
1918        path: Option<&HarnessRepositoryPathV1>,
1919    ) -> Result<(), HarnessOperatorApiError> {
1920        self.validate()?;
1921        self.origin.validate_for(run_id)?;
1922        if &self.mode != mode || self.path.as_ref() != path {
1923            return Err(HarnessOperatorApiError::InvalidGitDiff);
1924        }
1925        Ok(())
1926    }
1927}
1928
1929/// Node-scoped sibling of `HarnessRunGitDiffV1`.
1930#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1931#[serde(deny_unknown_fields)]
1932pub struct HarnessNodeGitDiffV1 {
1933    pub origin: HarnessNodeWorkspaceOriginV1,
1934    pub mode: HarnessGitDiffModeV1,
1935    pub path: Option<HarnessRepositoryPathV1>,
1936    pub text: String,
1937    pub truncated: bool,
1938}
1939
1940impl HarnessNodeGitDiffV1 {
1941    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1942        self.origin.validate()?;
1943        self.mode.validate()?;
1944        if let Some(path) = &self.path { path.validate()?; }
1945        if self.text.len() > HARNESS_GIT_DIFF_MAX_BYTES {
1946            return Err(HarnessOperatorApiError::InvalidGitDiff);
1947        }
1948        Ok(())
1949    }
1950
1951    pub fn validate_for(
1952        &self,
1953        node_id: &str,
1954        workspace_id: &str,
1955        mode: &HarnessGitDiffModeV1,
1956        path: Option<&HarnessRepositoryPathV1>,
1957    ) -> Result<(), HarnessOperatorApiError> {
1958        self.validate()?;
1959        self.origin.validate_for(node_id, workspace_id)?;
1960        if &self.mode != mode || self.path.as_ref() != path {
1961            return Err(HarnessOperatorApiError::InvalidGitDiff);
1962        }
1963        Ok(())
1964    }
1965}
1966
1967#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
1968#[serde(deny_unknown_fields)]
1969pub struct HarnessReverseAttributionWorkspaceV1 {
1970    pub node_id: HarnessSelectorV1,
1971    pub node_incarnation_id: HarnessNodeIncarnationV1,
1972    pub workspace_id: HarnessSelectorV1,
1973}
1974
1975impl HarnessReverseAttributionWorkspaceV1 {
1976    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
1977        self.node_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
1978        self.node_incarnation_id.validate()?;
1979        self.workspace_id.validate().map_err(HarnessOperatorApiError::Protocol)
1980    }
1981}
1982
1983#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
1984#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
1985pub enum HarnessReverseAttributionSubjectV1 {
1986    ManagedRecord {
1987        workspace: HarnessReverseAttributionWorkspaceV1,
1988        record_id: HarnessSelectorV1,
1989    },
1990    RuntimeSession {
1991        workspace: HarnessReverseAttributionWorkspaceV1,
1992        instance_id: u64,
1993        generation: u64,
1994    },
1995    Workspace {
1996        workspace: HarnessReverseAttributionWorkspaceV1,
1997    },
1998    FileScope {
1999        workspace: HarnessReverseAttributionWorkspaceV1,
2000        relative_path: HarnessRepositoryPathV1,
2001    },
2002    CommitScope {
2003        workspace: HarnessReverseAttributionWorkspaceV1,
2004        object_id: HarnessGitObjectIdV1,
2005    },
2006}
2007
2008impl HarnessReverseAttributionSubjectV1 {
2009    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
2010        let workspace = match self {
2011            Self::ManagedRecord { workspace, record_id } => {
2012                record_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2013                workspace
2014            }
2015            Self::RuntimeSession { workspace, instance_id, generation } => {
2016                if *instance_id == 0 || *generation == 0 {
2017                    return Err(HarnessOperatorApiError::InvalidReverseAttribution);
2018                }
2019                workspace
2020            }
2021            Self::Workspace { workspace } => workspace,
2022            Self::FileScope { workspace, relative_path } => {
2023                relative_path.validate()?;
2024                workspace
2025            }
2026            Self::CommitScope { workspace, object_id } => {
2027                object_id.validate()?;
2028                workspace
2029            }
2030        };
2031        workspace.validate()
2032    }
2033
2034    fn workspace(&self) -> &HarnessReverseAttributionWorkspaceV1 {
2035        match self {
2036            Self::ManagedRecord { workspace, .. }
2037            | Self::RuntimeSession { workspace, .. }
2038            | Self::Workspace { workspace }
2039            | Self::FileScope { workspace, .. }
2040            | Self::CommitScope { workspace, .. } => workspace,
2041        }
2042    }
2043}
2044
2045#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
2046#[serde(rename_all = "kebab-case")]
2047pub enum HarnessReverseAttributionRelationV1 {
2048    ManagedRecordBinding,
2049    RuntimeSessionBinding,
2050    WorkspaceBinding,
2051    WorkspaceScope,
2052}
2053
2054#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
2055#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
2056pub enum HarnessReverseAttributionBindingV1 {
2057    ManagedRecord {
2058        workspace: HarnessReverseAttributionWorkspaceV1,
2059        record_id: HarnessSelectorV1,
2060        active_instance_id: Option<u64>,
2061        active_generation: Option<u64>,
2062    },
2063    RuntimeSession {
2064        workspace: HarnessReverseAttributionWorkspaceV1,
2065        instance_id: u64,
2066        generation: u64,
2067    },
2068    Workspace {
2069        workspace: HarnessReverseAttributionWorkspaceV1,
2070    },
2071}
2072
2073impl HarnessReverseAttributionBindingV1 {
2074    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
2075        let workspace = match self {
2076            Self::ManagedRecord {
2077                workspace,
2078                record_id,
2079                active_instance_id,
2080                active_generation,
2081            } => {
2082                record_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2083                if active_instance_id.is_some() != active_generation.is_some()
2084                    || active_instance_id == &Some(0)
2085                    || active_generation == &Some(0)
2086                {
2087                    return Err(HarnessOperatorApiError::InvalidReverseAttribution);
2088                }
2089                workspace
2090            }
2091            Self::RuntimeSession { workspace, instance_id, generation } => {
2092                if *instance_id == 0 || *generation == 0 {
2093                    return Err(HarnessOperatorApiError::InvalidReverseAttribution);
2094                }
2095                workspace
2096            }
2097            Self::Workspace { workspace } => workspace,
2098        };
2099        workspace.validate()
2100    }
2101
2102    fn workspace(&self) -> &HarnessReverseAttributionWorkspaceV1 {
2103        match self {
2104            Self::ManagedRecord { workspace, .. }
2105            | Self::RuntimeSession { workspace, .. }
2106            | Self::Workspace { workspace } => workspace,
2107        }
2108    }
2109}
2110
2111#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
2112#[serde(deny_unknown_fields)]
2113pub struct HarnessReverseAttributionLinkV1 {
2114    pub task_id: HarnessTaskId,
2115    pub run_id: HarnessRunId,
2116    pub run_revision: HarnessRevision,
2117    pub binding: HarnessReverseAttributionBindingV1,
2118    pub relation: HarnessReverseAttributionRelationV1,
2119}
2120
2121impl HarnessReverseAttributionLinkV1 {
2122    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
2123        self.task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2124        self.run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2125        self.run_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
2126        self.binding.validate()?;
2127        let relation_matches = matches!(
2128            (&self.relation, &self.binding),
2129            (
2130                HarnessReverseAttributionRelationV1::ManagedRecordBinding,
2131                HarnessReverseAttributionBindingV1::ManagedRecord { .. },
2132            ) | (
2133                HarnessReverseAttributionRelationV1::RuntimeSessionBinding,
2134                HarnessReverseAttributionBindingV1::RuntimeSession { .. },
2135            ) | (
2136                HarnessReverseAttributionRelationV1::WorkspaceBinding
2137                    | HarnessReverseAttributionRelationV1::WorkspaceScope,
2138                HarnessReverseAttributionBindingV1::Workspace { .. },
2139            )
2140        );
2141        if !relation_matches {
2142            return Err(HarnessOperatorApiError::InvalidReverseAttribution);
2143        }
2144        Ok(())
2145    }
2146}
2147
2148#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2149#[serde(rename_all = "kebab-case")]
2150pub enum HarnessReverseAttributionOutcomeV1 {
2151    Attributed,
2152    Unattributed,
2153}
2154
2155#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2156#[serde(deny_unknown_fields)]
2157pub struct HarnessReverseAttributionV1 {
2158    pub subject: HarnessReverseAttributionSubjectV1,
2159    pub outcome: HarnessReverseAttributionOutcomeV1,
2160    pub links: Vec<HarnessReverseAttributionLinkV1>,
2161}
2162
2163impl HarnessReverseAttributionV1 {
2164    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
2165        self.subject.validate()?;
2166        if self.links.len() > HARNESS_REVERSE_ATTRIBUTION_LINKS_MAX
2167            || matches!(self.outcome, HarnessReverseAttributionOutcomeV1::Attributed)
2168                != !self.links.is_empty()
2169            || self.links.windows(2).any(|pair| pair[0] >= pair[1])
2170        {
2171            return Err(HarnessOperatorApiError::InvalidReverseAttribution);
2172        }
2173        for link in &self.links {
2174            link.validate()?;
2175            if link.binding.workspace() != self.subject.workspace()
2176                || !self.link_matches_subject(link)
2177            {
2178                return Err(HarnessOperatorApiError::InvalidReverseAttribution);
2179            }
2180        }
2181        Ok(())
2182    }
2183
2184    pub fn validate_for(
2185        &self,
2186        subject: &HarnessReverseAttributionSubjectV1,
2187    ) -> Result<(), HarnessOperatorApiError> {
2188        self.validate()?;
2189        if &self.subject != subject {
2190            return Err(HarnessOperatorApiError::InvalidReverseAttribution);
2191        }
2192        Ok(())
2193    }
2194
2195    fn link_matches_subject(&self, link: &HarnessReverseAttributionLinkV1) -> bool {
2196        match (&self.subject, &link.relation, &link.binding) {
2197            (
2198                HarnessReverseAttributionSubjectV1::ManagedRecord { record_id, .. },
2199                HarnessReverseAttributionRelationV1::ManagedRecordBinding,
2200                HarnessReverseAttributionBindingV1::ManagedRecord {
2201                    record_id: binding_record_id,
2202                    ..
2203                },
2204            ) => record_id == binding_record_id,
2205            (
2206                HarnessReverseAttributionSubjectV1::RuntimeSession {
2207                    instance_id,
2208                    generation,
2209                    ..
2210                },
2211                HarnessReverseAttributionRelationV1::RuntimeSessionBinding,
2212                HarnessReverseAttributionBindingV1::RuntimeSession {
2213                    instance_id: binding_instance_id,
2214                    generation: binding_generation,
2215                    ..
2216                },
2217            ) => instance_id == binding_instance_id && generation == binding_generation,
2218            (
2219                HarnessReverseAttributionSubjectV1::Workspace { .. },
2220                HarnessReverseAttributionRelationV1::WorkspaceBinding,
2221                HarnessReverseAttributionBindingV1::Workspace { .. },
2222            )
2223            | (
2224                HarnessReverseAttributionSubjectV1::FileScope { .. }
2225                    | HarnessReverseAttributionSubjectV1::CommitScope { .. },
2226                HarnessReverseAttributionRelationV1::WorkspaceScope,
2227                HarnessReverseAttributionBindingV1::Workspace { .. },
2228            ) => true,
2229            _ => false,
2230        }
2231    }
2232}
2233
2234/// Mirrors `gate4agent_types::ProviderSessionKey` exactly, for the same
2235/// reason `HarnessTerminalControlV1` duplicates `TerminalControl` rather than
2236/// importing it -- see that type's own doc comment.
2237#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2238#[serde(rename_all = "kebab-case")]
2239pub enum HarnessProviderSessionKeyV1 {
2240    SessionId,
2241    ConversationId,
2242}
2243
2244/// Mirrors `gate4agent_types::ProviderSessionIdentity`: the caller-supplied
2245/// reference `IndexProviderSession` binds a durable managed-session record
2246/// to. Bounds match the node's own `PROVIDER_EVENT_ID_MAX_BYTES`/
2247/// `PROVIDER_SESSION_LOCATOR_MAX_BYTES` (the node re-validates on its own
2248/// terms regardless; this bound exists to reject an obviously-oversized
2249/// request before it ever reaches C2).
2250#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2251#[serde(deny_unknown_fields)]
2252pub struct HarnessProviderSessionIdentityV1 {
2253    pub key: HarnessProviderSessionKeyV1,
2254    pub id: String,
2255    pub transcript_path: Option<String>,
2256}
2257
2258impl HarnessProviderSessionIdentityV1 {
2259    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
2260        if self.id.is_empty()
2261            || self.id.len() > HARNESS_PROVIDER_SESSION_ID_MAX_BYTES
2262            || self.id.starts_with('-')
2263            || self.id.chars().any(char::is_control)
2264            || self.transcript_path.as_ref().is_some_and(|path| {
2265                path.is_empty()
2266                    || path.len() > HARNESS_PROVIDER_SESSION_TRANSCRIPT_PATH_MAX_BYTES
2267                    || path.chars().any(char::is_control)
2268            })
2269        {
2270            return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
2271        }
2272        Ok(())
2273    }
2274}
2275
2276/// Mirrors the node's own `SessionTaskTargetV1` shape exactly (`New` starts
2277/// a fresh correlation, `Existing` binds to a caller-named task, `Clear`
2278/// removes the binding). The node's `task_id` there is its own local
2279/// identifier space (format `task-<24 lowercase hex>`, unrelated to this
2280/// crate's `HarnessTaskId` despite the similar name -- the node layer has no
2281/// dependency on `hatchery-harness-protocol` and never mints one from a
2282/// `HarnessTaskId`), so `task_id` here stays a bounded opaque string rather
2283/// than the typed `HarnessTaskId`: the harness-service boundary parses and
2284/// validates it against the node's own format when building the C2 request.
2285#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2286#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
2287pub enum HarnessSessionTaskTargetV1 {
2288    New,
2289    Existing { task_id: String },
2290    Clear,
2291}
2292
2293impl HarnessSessionTaskTargetV1 {
2294    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
2295        if let Self::Existing { task_id } = self {
2296            if !valid_runtime_id(task_id, HARNESS_SESSION_RECORD_TASK_ID_MAX_BYTES) {
2297                return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
2298            }
2299        }
2300        Ok(())
2301    }
2302}
2303
2304/// Exact mirror of `gate4agent_types::ProviderInteractionKind`: this crate
2305/// has no dependency on `gate4agent-types` (see the doc comment on
2306/// `HarnessTerminalControlV1`), so the ACP interaction classification the
2307/// node resolves a `correlation_id` against is duplicated here as its own
2308/// closed wire enum, the same way `HarnessAgentStreamChunkKindV1::
2309/// InteractionPrompt` carries it on the outbound side.
2310#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2311#[serde(rename_all = "kebab-case")]
2312pub enum HarnessProviderInteractionKindV1 {
2313    Approval,
2314    Question,
2315}
2316
2317/// Exact mirror of `gate4agent_types::ProviderInteractionResponse`: this
2318/// crate has no dependency on `gate4agent-types` (see the doc comment on
2319/// `HarnessTerminalControlV1`), so `ResolveInteraction`'s answer payload is
2320/// duplicated here as its own closed wire enum rather than imported.
2321/// Unlike the source type's own `validate_for`, `validate()` below does not
2322/// check `Answer`/`ApproveOnce`/`Deny` against the live interaction's own
2323/// `HarnessProviderInteractionKindV1` (`Approval` vs `Question`) -- that
2324/// check needs the interaction the node is holding under `correlation_id`
2325/// and stays the node's job, not this wire's, the same way
2326/// `WriteSessionInput` never checks that a PTY exists before sending. It
2327/// bounds `Answer { text }` to `HARNESS_ACP_INTERACTION_RESPONSE_MAX_BYTES`
2328/// and rejects unsafe control bytes, but not emptiness: the source type's
2329/// `EmptyAnswer` rule applies only when the interaction turns out to be a
2330/// `Question`, which this wire does not know either.
2331#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2332#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
2333pub enum HarnessProviderInteractionResponseV1 {
2334    ApproveOnce,
2335    Deny,
2336    Answer { text: String },
2337}
2338
2339impl HarnessProviderInteractionResponseV1 {
2340    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
2341        match self {
2342            Self::ApproveOnce | Self::Deny => Ok(()),
2343            Self::Answer { text } => {
2344                if text.len() > HARNESS_ACP_INTERACTION_RESPONSE_MAX_BYTES
2345                    || contains_unsafe_control_bytes(text)
2346                {
2347                    return Err(HarnessOperatorApiError::InvalidSessionControl);
2348                }
2349                Ok(())
2350            }
2351        }
2352    }
2353}
2354
2355#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2356#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
2357pub enum HarnessOperatorRequestV1 {
2358    MonitorGet { run_id: HarnessRunId },
2359    TimelineRead {
2360        run_id: HarnessRunId,
2361        after_sequence: Option<u64>,
2362        limit: u16,
2363    },
2364    TasksList {
2365        after_task_id: Option<HarnessTaskId>,
2366        state: Option<HarnessTaskStateV1>,
2367        /// "What did this task spawn": restricts the page to tasks whose
2368        /// own `parent_task_id` equals this one -- direct children only,
2369        /// never deeper descendants. Additive over the original bare
2370        /// `{ after_task_id, state, limit }` shape (`#[serde(default)]`
2371        /// keeps an older caller decoding), mirrors `HarnessReadRequestV1::
2372        /// TasksList`'s own addition of the same filter.
2373        #[serde(default)]
2374        parent_task_id: Option<HarnessTaskId>,
2375        limit: u16,
2376    },
2377    TaskGet { task_id: HarnessTaskId },
2378    /// D5, Slice D: the operator's own per-task operations ledger --
2379    /// `HarnessEngine::operations_for_task`'s bounded, newest-first page,
2380    /// with the raw `HarnessActorV1` (the operator is already trusted with
2381    /// every other raw identity this wire exposes; the agent side never
2382    /// sees this, only the category -- see `HarnessOperationLedgerEntryV1`'s
2383    /// own doc comment).
2384    TaskOperations { task_id: HarnessTaskId, limit: u16 },
2385    RunsList {
2386        task_id: Option<HarnessTaskId>,
2387        after_run_id: Option<HarnessRunId>,
2388        lifecycle: Option<HarnessRunLifecycleV1>,
2389        /// "What did this run spawn": restricts the page to runs whose own
2390        /// `parent_run_id` equals this one. Additive (`#[serde(default)]`),
2391        /// mirrors `HarnessReadRequestV1::RunsList`'s own addition.
2392        #[serde(default)]
2393        parent_run_id: Option<HarnessRunId>,
2394        limit: u16,
2395    },
2396    RunGet { run_id: HarnessRunId },
2397    RunCorrelationGet { run_id: HarnessRunId },
2398    RunTransferGet { run_id: HarnessRunId },
2399    ReverseAttributionGet { subject: HarnessReverseAttributionSubjectV1 },
2400    ObserveRunContextSource { run_id: HarnessRunId },
2401    InspectRunWorkspace { run_id: HarnessRunId },
2402    ReadRunWorkspaceFile {
2403        run_id: HarnessRunId,
2404        path: HarnessRepositoryPathV1,
2405    },
2406    ReadRunGitHistory {
2407        run_id: HarnessRunId,
2408        path: Option<HarnessRepositoryPathV1>,
2409        before: Option<HarnessGitObjectIdV1>,
2410        limit: u16,
2411    },
2412    ReadRunGitDiff {
2413        run_id: HarnessRunId,
2414        mode: HarnessGitDiffModeV1,
2415        path: Option<HarnessRepositoryPathV1>,
2416    },
2417    // Node-scoped siblings of the `*RunWorkspace*`/`*RunGit*` family above:
2418    // routed straight from a node/workspace pair with no run binding, for
2419    // sidebar Files/Git reads that have no run in flight (harness-mode
2420    // parity with the direct-C2 TUI's `InspectWorkspace`).
2421    InspectNodeWorkspace { node_id: String, workspace_id: String },
2422    ReadNodeWorkspaceFile {
2423        node_id: String,
2424        workspace_id: String,
2425        path: HarnessRepositoryPathV1,
2426    },
2427    ReadNodeGitHistory {
2428        node_id: String,
2429        workspace_id: String,
2430        path: Option<HarnessRepositoryPathV1>,
2431        before: Option<HarnessGitObjectIdV1>,
2432        limit: u16,
2433    },
2434    ReadNodeGitDiff {
2435        node_id: String,
2436        workspace_id: String,
2437        mode: HarnessGitDiffModeV1,
2438        path: Option<HarnessRepositoryPathV1>,
2439    },
2440    // V11 write/create siblings of the four node-workspace read verbs above:
2441    // same direct node/workspace-pair routing (`exact_route`, no run
2442    // binding involved), just a write instead of a read -- the harness-mode
2443    // twin of the light TUI editor's save/create-file/create-directory
2444    // affordances. `WriteNodeWorkspaceFile` relays `NodeRequest::
2445    // WriteWorkspaceFile`: the node's own CAS write, gated on
2446    // `expected_revision` matching the file's current on-disk SHA-256. A
2447    // stale value comes back as the node's `RepositoryFileRevisionConflict`
2448    // failure code, which this wire surfaces as the typed `Conflict` host
2449    // error (see `map_node_workspace_write_error` in
2450    // `hatchery-harness-service`), never collapsed into `Internal`.
2451    // `CreateNodeWorkspaceFile`/`CreateNodeWorkspaceDirectory` relay the
2452    // node's own two distinct creation verbs (`NodeRequest::
2453    // CreateWorkspaceFile`/`CreateWorkspaceDirectory`) -- the node models
2454    // file and directory creation as two separate requests, not one verb
2455    // with a kind flag, so this wire mirrors that shape exactly rather than
2456    // inventing a combined one.
2457    WriteNodeWorkspaceFile {
2458        node_id: String,
2459        workspace_id: String,
2460        path: HarnessRepositoryPathV1,
2461        content: String,
2462        expected_revision: HarnessWorkspaceFileRevisionV1,
2463    },
2464    CreateNodeWorkspaceFile {
2465        node_id: String,
2466        workspace_id: String,
2467        path: HarnessRepositoryPathV1,
2468    },
2469    CreateNodeWorkspaceDirectory {
2470        node_id: String,
2471        workspace_id: String,
2472        path: HarnessRepositoryPathV1,
2473    },
2474    LaunchPlansList {
2475        after_plan_id: Option<HarnessSelectorV1>,
2476        limit: u16,
2477    },
2478    TaskExecutionSpecGet { task_id: HarnessTaskId },
2479    /// `provider`/`workspace`/`plan_id` filter the derived plan catalogue,
2480    /// `after` pages it (same idiom as `LaunchPlansList`'s `after_plan_id`):
2481    /// the catalogue itself has no cap (see `HarnessTaskLaunchOptionsV1`'s
2482    /// own doc comment), only a page of it does. All four are additive over
2483    /// the original bare `{ task_id }` shape -- `#[serde(default)]` so an
2484    /// older caller that only ever sent `task_id` keeps deserializing.
2485    TaskLaunchOptionsGet {
2486        task_id: HarnessTaskId,
2487        #[serde(default)]
2488        provider: Option<HarnessSelectorV1>,
2489        #[serde(default)]
2490        workspace: Option<HarnessSelectorV1>,
2491        #[serde(default)]
2492        plan_id: Option<HarnessSelectorV1>,
2493        #[serde(default)]
2494        after: Option<HarnessSelectorV1>,
2495    },
2496    RuntimeInventoryList {
2497        after_node_id: Option<String>,
2498        limit: u16,
2499    },
2500    TerminalRead {
2501        session: HarnessRuntimeSessionAddressV1,
2502        after_sequence: Option<u64>,
2503        limit: u16,
2504    },
2505    // Direct operator session verbs: unlike the CAS/task-mutation family
2506    // below (`CreateTask`..`StartTaskV2`, closed-set `HarnessOperatorActionV1`
2507    // shape), a session spawn/write/resize/stop has no task to CAS against --
2508    // it relays straight to C2 the same way `TerminalRead` reads straight
2509    // from C2, just as a write instead of a read. See the harness-service
2510    // host's async select loop (not `execute_operator_request`, which is
2511    // synchronous with no C2 handle in scope) for the dispatch.
2512    SpawnSession {
2513        node_id: String,
2514        workspace_id: String,
2515        provider: String,
2516        provider_profile: String,
2517        mode: HarnessExecutionModeV1,
2518        terminal_size: HarnessRuntimeTerminalSizeV1,
2519        // `None` means "the axis default" (`ApprovalLevel::FullAuto` --
2520        // not asking a human is the owner's stated norm and this field does
2521        // not change that default), so every caller that predates this
2522        // field, and every caller that simply does not care, is unchanged.
2523        // `Some(_)` is the one way `Moderate`/`ReadOnly`/`Unmanaged` becomes
2524        // reachable on a session spawned through this wire at all -- see
2525        // `hatchery-node`'s `require_session_runtime_policy` doc and
2526        // `HostPolicy::Yolo`'s own doc for why `FullAuto` alone left the
2527        // whole permission-deferral path dead code in production.
2528        #[serde(default)]
2529        approval_level: Option<HarnessApprovalLevelV1>,
2530    },
2531    WriteSessionInput {
2532        session: HarnessRuntimeSessionAddressV1,
2533        text: String,
2534    },
2535    // `PromptSession` relays to `NodeRequest::Prompt`, the node's semantic-
2536    // prompt verb -- a DIFFERENT node request from the one immediately
2537    // above. `WriteSessionInput` relays to `NodeRequest::Input` (typed/raw
2538    // PTY text) and keeps meaning exactly that; this is an additional verb,
2539    // not a reinterpretation of it, the same way `PasteSession` relaying
2540    // `NodeRequest::Paste` is additional to it rather than a replacement.
2541    // Bounded by the same `HARNESS_SESSION_INPUT_MAX_BYTES` ceiling
2542    // `WriteSessionInput`/`PasteSession` already use (see that constant's
2543    // doc comment) -- the node checks all three against the same single
2544    // `MAX_NODE_TEXT_BYTES`, so there is no separate per-verb bound to pick.
2545    //
2546    // Admissible only against an ACP- or inline-transport session, and
2547    // refused BY NAME for a PTY-transport one -- unconditionally, even for
2548    // the rare PTY session whose provider profile currently admits a node-
2549    // level `SemanticPrompt` runtime policy (reachable today only through
2550    // `ResumeSessionRecord`'s own `initial_prompt`, a narrow capability for
2551    // typing exactly one prompt into a raw terminal at resume time, for a
2552    // provider CLI a human is watching). This is a deliberate, permanent
2553    // boundary the owner drew, not a limitation waiting to be lifted: a PTY
2554    // session has a human sitting at it, choosing what runs and watching
2555    // what appears, so what lands on that screen is the operator's
2556    // business, never this wire's. ACP and inline are the two transports
2557    // this project opens with nobody at a terminal, and prompting is their
2558    // ordinary interface -- `PromptSession` and `WriteSessionInput` must
2559    // never silently fall back to each other across that line.
2560    //
2561    // Enforced in `hatchery-harness-service`, not here: this wire type
2562    // carries only a bare routing address (`HarnessRuntimeSessionAddressV1`
2563    // has no transport field of its own), and tightening the node's shared
2564    // `NodeRequest::Prompt` handler itself would also narrow the
2565    // `ResumeSessionRecord` capability above, which this change does not
2566    // touch. `hatchery-harness-service` checks its own cached runtime
2567    // inventory before ever dispatching to C2/the node and refuses by name
2568    // (`HarnessOperatorHostErrorV1::UnsupportedTransport`) when that cache
2569    // already knows the target is PTY; the node's existing
2570    // `require_session_runtime_policy(SemanticPrompt)` gate remains the
2571    // backstop for the narrow window right after a spawn/resume where the
2572    // cache cannot yet prove the transport either way.
2573    PromptSession {
2574        session: HarnessRuntimeSessionAddressV1,
2575        text: String,
2576    },
2577    ResizeSession {
2578        session: HarnessRuntimeSessionAddressV1,
2579        terminal_size: HarnessRuntimeTerminalSizeV1,
2580    },
2581    StopSession {
2582        session: HarnessRuntimeSessionAddressV1,
2583        force: bool,
2584    },
2585    // V11 siblings of the four V10 session verbs above -- the same direct,
2586    // no-CAS C2 relay, just five more `NodeRequest` shapes the light TUI's
2587    // terminal already sends. `ControlSession` relays `NodeRequest::
2588    // TerminalControl` (special keys: Enter, Ctrl-C, arrows, ...).
2589    // `WriteSessionBytes` relays `NodeRequest::TerminalBytes` (a short raw
2590    // byte sequence, bounded well below `WriteSessionInput`'s text ceiling --
2591    // see `HARNESS_SESSION_BYTES_MAX_BYTES`). `PasteSession` relays
2592    // `NodeRequest::Paste`: a distinct node-level verb from `WriteSessionInput`
2593    // (the node frames it as a semantic bracketed paste, not plain typed
2594    // text), so it gets its own wire verb rather than folding into
2595    // `WriteSessionInput`. `RemoveSession` relays `NodeRequest::Remove`,
2596    // clearing an exited/failed session's binding from the node -- the same
2597    // node-side call a successful `StopSession` already fires as a best-effort
2598    // follow-up reap (see `HarnessC2Adapter::remove_stopped_session`'s doc
2599    // comment in `hatchery-harness-service`), now exposed as its own
2600    // first-class verb for a session that reached that state on its own.
2601    // `ResumeSession` relays `NodeRequest::Resume` and, unlike `SpawnSession`,
2602    // acks dispatch only: the node never returns a new address for it (same
2603    // `instance_id`, generation bumped only once the resume actually settles,
2604    // reported through the runtime inventory rather than this reply).
2605    ControlSession {
2606        session: HarnessRuntimeSessionAddressV1,
2607        control: HarnessTerminalControlV1,
2608    },
2609    WriteSessionBytes {
2610        session: HarnessRuntimeSessionAddressV1,
2611        bytes: Vec<u8>,
2612    },
2613    PasteSession {
2614        session: HarnessRuntimeSessionAddressV1,
2615        text: String,
2616    },
2617    RemoveSession {
2618        session: HarnessRuntimeSessionAddressV1,
2619    },
2620    ResumeSession {
2621        session: HarnessRuntimeSessionAddressV1,
2622        terminal_size: HarnessRuntimeTerminalSizeV1,
2623    },
2624    // The four ACP control verbs, session-address-scoped exactly like the
2625    // eight session-control verbs above and relayed the same direct,
2626    // no-CAS way to C2/node -- gated node-side behind its own
2627    // `acp-control-v1` capability (a peer that predates it never negotiates
2628    // it and never sees these sent), so asking one of a PTY or inline
2629    // session is refused by name rather than silently accepted. They answer
2630    // what the outbound `SubscribeAgentStream` content stream reports (see
2631    // `HarnessOperatorAgentEventV1`), not what `SubscribeEvents`'s
2632    // task/run/node telemetry or `TimelineRead`'s `ObservationV1` stream
2633    // can: neither carries the question text, the option list, or the
2634    // mode/config/model catalogs these verbs act on
2635    // (`docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
2636    // §3-5). All four ack with a bare unit reply
2637    // (`InteractionResolved`/`SessionModeSet`/`SessionConfigOptionSet`/
2638    // `SessionModelSet`), the same shape `WriteSessionInput` acks with,
2639    // because the node itself acks all four with its own bare
2640    // `NodeResponse::Accepted`.
2641    //
2642    // `ResolveInteraction` answers the exact `correlation_id` the
2643    // observation stream already minted onto `ObservationKindV1::
2644    // ApprovalRequested`/`QuestionRequested`. `response` must additionally
2645    // match the interaction's own kind (`Approval` vs `Question`) -- that
2646    // check needs the live interaction the node holds and stays the node's
2647    // job, not this wire's, the same way `WriteSessionInput` never checks
2648    // that a PTY exists before sending.
2649    ResolveInteraction {
2650        session: HarnessRuntimeSessionAddressV1,
2651        correlation_id: String,
2652        response: HarnessProviderInteractionResponseV1,
2653    },
2654    SetSessionMode {
2655        session: HarnessRuntimeSessionAddressV1,
2656        mode_id: String,
2657    },
2658    SetSessionConfigOption {
2659        session: HarnessRuntimeSessionAddressV1,
2660        option_id: String,
2661        value_json: String,
2662    },
2663    SetSessionModel {
2664        session: HarnessRuntimeSessionAddressV1,
2665        model_id: String,
2666    },
2667    CatalogNativeSessions {
2668        route: HarnessNativeSessionRouteV1,
2669        limit: u16,
2670    },
2671    PageNativeSessions {
2672        route: HarnessNativeSessionRouteV1,
2673        window: HarnessNativeSessionCatalogWindowV1,
2674        catalog_revision: u64,
2675        recent_cutoff_unix_ms: u64,
2676        after_selection_id: Option<String>,
2677        limit: u16,
2678    },
2679    PreviewNativeSession {
2680        selection: HarnessNativeSessionSelectionV1,
2681        message_limit: u16,
2682    },
2683    // Session-record family: node-scoped like `InspectNodeWorkspace`
2684    // (`node_id` resolved live via `exact_route`, no caller-pinned
2685    // incarnation), not session-address-scoped like the eight session-
2686    // control verbs above -- a managed session record has its own
2687    // `record_id` identity independent of whether it currently has a live
2688    // PTY bound to it. `PreviewSessionRecord` relays `NodeRequest::
2689    // PreviewSessionRecord` and rides the same read-worker pool as
2690    // `CatalogNativeSessions`/`PageNativeSessions`/`PreviewNativeSession`
2691    // above (`is_native_history_request` in `hatchery-harness-service`) --
2692    // the light TUI sends this exact node request for both an initial
2693    // preview open and a background history refresh of an already-open
2694    // preview tab, so this wire stays a single verb the same way.
2695    PreviewSessionRecord {
2696        node_id: String,
2697        record_id: String,
2698        message_limit: u16,
2699    },
2700    // `ResumeSessionRecord`/`RenameSessionRecord`/`SetSessionTask`/
2701    // `ForgetSessionRecord`/`IndexProviderSession`/`IndexNativeSession` are
2702    // mutations, dispatched through the bounded session-record-mutation
2703    // worker pool (`is_session_record_mutation_request`) the same way the
2704    // eight session-control verbs use their own pool -- unlike those eight,
2705    // each of these six carries a distinct, non-`Accepted` node reply (a
2706    // managed-session record, or for `ResumeSessionRecord` a record plus the
2707    // freshly spawned session address), so the reply is relayed back
2708    // correlated rather than collapsed into one shared ack shape. Unlike
2709    // `ResumeSession` (existing live session, ack-only, generation bump
2710    // reported through the runtime inventory), `ResumeSessionRecord` spawns
2711    // a *new* session from a dormant record and the node returns that
2712    // session's address synchronously -- this wire mirrors that exactly.
2713    ResumeSessionRecord {
2714        node_id: String,
2715        record_id: String,
2716        terminal_size: HarnessRuntimeTerminalSizeV1,
2717        initial_prompt: Option<String>,
2718    },
2719    RenameSessionRecord {
2720        node_id: String,
2721        record_id: String,
2722        display_name: String,
2723    },
2724    SetSessionTask {
2725        node_id: String,
2726        record_id: String,
2727        expected_revision: u64,
2728        target: HarnessSessionTaskTargetV1,
2729    },
2730    ForgetSessionRecord {
2731        node_id: String,
2732        record_id: String,
2733    },
2734    IndexProviderSession {
2735        node_id: String,
2736        workspace_id: String,
2737        provider: String,
2738        identity: HarnessProviderSessionIdentityV1,
2739        display_name: String,
2740    },
2741    // Unlike the five siblings above, routed via `selection.route` (same
2742    // caller-pinned-incarnation shape `CatalogNativeSessions`/
2743    // `PageNativeSessions`/`PreviewNativeSession` already use) rather than a
2744    // bare `node_id`: the selection being committed into a durable record
2745    // was chosen from a previously fetched native-session catalog page, so
2746    // it carries its own route already. Still dispatched through the
2747    // session-record-mutation pool, not the native-history read pool --
2748    // it mutates the managed-session store, it does not read a catalog.
2749    IndexNativeSession {
2750        selection: HarnessNativeSessionSelectionV1,
2751        display_name: String,
2752    },
2753    CreateTask { request: HarnessCreateTaskRequestV1 },
2754    ReplaceTask { request: HarnessReplaceTaskRequestV1 },
2755    MoveTask { request: HarnessMoveTaskRequestV1 },
2756    CancelTask { request: HarnessCancelTaskRequestV1 },
2757    RetryTask { request: HarnessRetryTaskRequestV1 },
2758    ScheduleNext { request: HarnessScheduleNextRequestV1 },
2759    ReplaceTaskExecutionSpec { request: HarnessReplaceTaskExecutionSpecRequestV1 },
2760    StartTask { request: HarnessStartTaskRequestV1 },
2761    ReplaceTaskExecutionSpecV2 { request: HarnessReplaceTaskExecutionSpecRequestV2 },
2762    StartTaskV2 { request: HarnessStartTaskRequestV2 },
2763    /// Starts the issued task the same way `StartTaskV2` does, then mints
2764    /// the dispatch's default harness-MCP grant through
2765    /// `runtime::resolve_harness_mcp_grant` (the production mint, not a test
2766    /// helper). Refused unless the issued plan is `GrantBound` with an
2767    /// `Operator` grant policy. The reply is the grant id and revision; it
2768    /// does not carry a read credential.
2769    MintHarnessMcpGrant { request: HarnessStartTaskRequestV2 },
2770    SubmitIntent { intent: HarnessOperatorIntentV1 },
2771    // Node-scoped, paged host-directory listing behind the folder-browser
2772    // dialog: the harness-mode sibling of the light TUI's own
2773    // `AppAction::BrowseHostDirectories`, relaying `NodeRequest::
2774    // BrowseHostDirectories` directly. No `workspace_id`: unlike every read
2775    // above, a host-directory browse targets the node's host filesystem, not
2776    // a registered workspace, so it does not fit `InspectNodeWorkspace`'s
2777    // family (see `PreparedHostDirectoryBrowse`'s doc comment in
2778    // `hatchery-harness-service` for why this rides its own bounded read
2779    // lane rather than that family's). `directory: None` opens the node's
2780    // default root; `after` pages a previously returned `next_after` cursor.
2781    BrowseHostDirectories {
2782        node_id: String,
2783        directory: Option<HarnessHostPathV1>,
2784        after: Option<HarnessHostPathV1>,
2785    },
2786    // `RegisterWorkspace`/`UnregisterWorkspace`/`CreateStandaloneWorkspace`/
2787    // `CreateWorktree`/`RemoveWorktree`/`ExportContextPack`/
2788    // `ForgetContextPack` are the resource-mutation family: workspace/
2789    // worktree lifecycle plus context-pack export/forget, dispatched through
2790    // the bounded resource-mutation worker pool (`is_resource_mutation_
2791    // request` in `hatchery-harness-service`) the same way the session-
2792    // record family uses its own pool -- see `ResourceMutationKind`'s doc
2793    // comment there for why these seven heterogeneous verbs share one lane.
2794    // `RegisterWorkspace`'s success (like every workspace/worktree lifecycle
2795    // verb in this family) adds an entry to the node's `workspaces` map,
2796    // which the runtime inventory's own roster caches
2797    // (`HarnessRuntimeInventoryV1::workspaces`) -- the host invalidates that
2798    // route's cached entry unconditionally on success, so a harness-mode
2799    // sidebar converges the same way it does after a session-record
2800    // mutation.
2801    RegisterWorkspace {
2802        node_id: String,
2803        workspace_id: String,
2804        root: HarnessHostPathV1,
2805    },
2806    UnregisterWorkspace {
2807        node_id: String,
2808        workspace_id: String,
2809    },
2810    CreateStandaloneWorkspace {
2811        node_id: String,
2812        workspace_id: String,
2813        root: HarnessHostPathV1,
2814        initial_branch: Option<String>,
2815    },
2816    CreateWorktree {
2817        node_id: String,
2818        source_workspace_id: String,
2819        workspace_id: String,
2820        target_root: HarnessHostPathV1,
2821        branch: String,
2822        base: Option<String>,
2823    },
2824    RemoveWorktree {
2825        node_id: String,
2826        source_workspace_id: String,
2827        target_root: HarnessHostPathV1,
2828    },
2829    // Session-address-scoped, not node-id-scoped like the five workspace/
2830    // worktree verbs above: relays `NodeRequest::ExportContextPack` exactly
2831    // the way the eight session-control verbs relay their own node requests
2832    // from a `HarnessRuntimeSessionAddressV1` -- the node/incarnation this
2833    // targets comes from `session.node_id`/`session.incarnation_id`, so
2834    // there is no separate `node_id` field. Reuses
2835    // `HarnessResolvedContextPackReceiptV1` (already defined for the A2 run-
2836    // continuation arc) as its reply verbatim rather than inventing a
2837    // duplicate: the node's own `ResolvedContextPackReceipt` is the exact
2838    // same payload shape either way `ExportContextPack`/
2839    // `ExportContextPackForSessionRecord` produces it.
2840    ExportContextPack {
2841        session: HarnessRuntimeSessionAddressV1,
2842    },
2843    // Node-scoped like the five workspace/worktree verbs above, not session-
2844    // address-scoped like `ExportContextPack`: forgetting an exported
2845    // context pack from the node's `ContextPackStore` needs only the node
2846    // and the pack's own id, no session or incarnation binding.
2847    ForgetContextPack {
2848        node_id: String,
2849        context_id: HarnessSelectorV1,
2850    },
2851    // Opens a long-lived, server-push subscription instead of the usual
2852    // one-shot request/reply: see the framing note on `HarnessOperatorEventV1`
2853    // for the wire shape this switches the connection into.
2854    SubscribeEvents {},
2855    // Same "opens a long-lived, server-push subscription" framing as
2856    // SubscribeEvents immediately above, on its own connection: see
2857    // `HarnessOperatorTerminalEventV1`'s doc comment for why terminal
2858    // frames do not ride SubscribeEvents's own queue. `sessions` is
2859    // declared once, at subscribe time -- this wire has no representable
2860    // second client-to-host message (see the module doc), so a client
2861    // that opens or closes a pane reconnects with the updated list rather
2862    // than patching an existing subscription.
2863    SubscribeTerminal { sessions: Vec<HarnessRuntimeSessionAddressV1> },
2864    // Same "opens a long-lived, server-push subscription" framing as
2865    // SubscribeTerminal immediately above, on its own connection, for the
2866    // ACP agent-content stream (node-side `agent-stream-events-v1`) rather
2867    // than terminal frames -- see `HarnessOperatorAgentEventV1`'s doc
2868    // comment for why that push channel needs its own event type distinct
2869    // from both `HarnessOperatorEventV1` and `HarnessOperatorTerminalEventV1`.
2870    // `sessions` is declared once, at subscribe time, for the same reason
2871    // SubscribeTerminal's is (see the module doc): a client that opens or
2872    // closes a pane reconnects with the updated list rather than patching
2873    // an existing subscription.
2874    SubscribeAgentStream { sessions: Vec<HarnessRuntimeSessionAddressV1> },
2875}
2876
2877impl HarnessOperatorRequestV1 {
2878    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
2879        match self {
2880            Self::MonitorGet { run_id } => {
2881                run_id.validate().map_err(HarnessOperatorApiError::Protocol)
2882            }
2883            Self::TimelineRead { run_id, after_sequence, limit } => {
2884                run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2885                if after_sequence == &Some(0) {
2886                    return Err(HarnessOperatorApiError::InvalidCursor);
2887                }
2888                validate_operator_timeline_limit(*limit)
2889            }
2890            Self::TasksList { after_task_id, parent_task_id, limit, .. } => {
2891                if let Some(task_id) = after_task_id {
2892                    task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2893                }
2894                if let Some(task_id) = parent_task_id {
2895                    task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2896                }
2897                validate_operator_limit(*limit)
2898            }
2899            Self::TaskGet { task_id } => {
2900                task_id.validate().map_err(HarnessOperatorApiError::Protocol)
2901            }
2902            Self::TaskOperations { task_id, limit } => {
2903                task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2904                validate_operator_limit(*limit)
2905            }
2906            Self::RunsList { task_id, after_run_id, parent_run_id, limit, .. } => {
2907                if let Some(task_id) = task_id {
2908                    task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2909                }
2910                if let Some(run_id) = after_run_id {
2911                    run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2912                }
2913                if let Some(run_id) = parent_run_id {
2914                    run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2915                }
2916                validate_operator_limit(*limit)
2917            }
2918            Self::RunGet { run_id }
2919            | Self::RunCorrelationGet { run_id }
2920            | Self::RunTransferGet { run_id }
2921            | Self::ObserveRunContextSource { run_id }
2922            | Self::InspectRunWorkspace { run_id } => {
2923                run_id.validate().map_err(HarnessOperatorApiError::Protocol)
2924            }
2925            Self::ReverseAttributionGet { subject } => subject.validate(),
2926            Self::ReadRunWorkspaceFile { run_id, path } => {
2927                run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2928                path.validate()
2929            }
2930            Self::ReadRunGitHistory { run_id, path, before, limit } => {
2931                run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2932                if let Some(path) = path { path.validate()?; }
2933                if let Some(before) = before { before.validate()?; }
2934                if !(1..=HARNESS_GIT_HISTORY_LIMIT_MAX).contains(limit) {
2935                    return Err(HarnessOperatorApiError::InvalidLimit);
2936                }
2937                Ok(())
2938            }
2939            Self::ReadRunGitDiff { run_id, mode, path } => {
2940                run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2941                mode.validate()?;
2942                if let Some(path) = path { path.validate()?; }
2943                Ok(())
2944            }
2945            Self::InspectNodeWorkspace { node_id, workspace_id } => {
2946                validate_node_workspace_route(node_id, workspace_id)
2947            }
2948            Self::ReadNodeWorkspaceFile { node_id, workspace_id, path } => {
2949                validate_node_workspace_route(node_id, workspace_id)?;
2950                path.validate()
2951            }
2952            Self::ReadNodeGitHistory { node_id, workspace_id, path, before, limit } => {
2953                validate_node_workspace_route(node_id, workspace_id)?;
2954                if let Some(path) = path { path.validate()?; }
2955                if let Some(before) = before { before.validate()?; }
2956                if !(1..=HARNESS_GIT_HISTORY_LIMIT_MAX).contains(limit) {
2957                    return Err(HarnessOperatorApiError::InvalidLimit);
2958                }
2959                Ok(())
2960            }
2961            Self::ReadNodeGitDiff { node_id, workspace_id, mode, path } => {
2962                validate_node_workspace_route(node_id, workspace_id)?;
2963                mode.validate()?;
2964                if let Some(path) = path { path.validate()?; }
2965                Ok(())
2966            }
2967            Self::WriteNodeWorkspaceFile {
2968                node_id, workspace_id, path, content, expected_revision,
2969            } => {
2970                validate_node_workspace_route(node_id, workspace_id)?;
2971                path.validate()?;
2972                expected_revision.validate()?;
2973                if content.len() > HARNESS_WORKSPACE_FILE_MAX_BYTES {
2974                    return Err(HarnessOperatorApiError::InvalidWorkspaceFile);
2975                }
2976                Ok(())
2977            }
2978            Self::CreateNodeWorkspaceFile { node_id, workspace_id, path }
2979            | Self::CreateNodeWorkspaceDirectory { node_id, workspace_id, path } => {
2980                validate_node_workspace_route(node_id, workspace_id)?;
2981                path.validate()
2982            }
2983            Self::LaunchPlansList { after_plan_id, limit } => {
2984                if let Some(plan_id) = after_plan_id {
2985                    plan_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2986                }
2987                if !(1..=HARNESS_LAUNCH_PLAN_PAGE_LIMIT_MAX).contains(limit) {
2988                    return Err(HarnessOperatorApiError::InvalidLimit);
2989                }
2990                Ok(())
2991            }
2992            Self::TaskExecutionSpecGet { task_id } => {
2993                task_id.validate().map_err(HarnessOperatorApiError::Protocol)
2994            }
2995            Self::TaskLaunchOptionsGet { task_id, provider, workspace, plan_id, after } => {
2996                task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
2997                if let Some(provider) = provider {
2998                    provider.validate().map_err(HarnessOperatorApiError::Protocol)?;
2999                }
3000                if let Some(workspace) = workspace {
3001                    workspace.validate().map_err(HarnessOperatorApiError::Protocol)?;
3002                }
3003                if let Some(plan_id) = plan_id {
3004                    plan_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
3005                }
3006                if let Some(after) = after {
3007                    after.validate().map_err(|_| HarnessOperatorApiError::InvalidCursor)?;
3008                }
3009                Ok(())
3010            }
3011            Self::RuntimeInventoryList { after_node_id, limit } => {
3012                if !(1..=HARNESS_RUNTIME_INVENTORY_PAGE_LIMIT_MAX).contains(limit) {
3013                    return Err(HarnessOperatorApiError::InvalidLimit);
3014                }
3015                if after_node_id.as_deref().is_some_and(|node_id| {
3016                    !valid_runtime_id(node_id, 128)
3017                }) {
3018                    return Err(HarnessOperatorApiError::InvalidCursor);
3019                }
3020                Ok(())
3021            }
3022            Self::TerminalRead { session, limit, .. } => {
3023                // after_sequence is unconstrained: node terminal sequences are
3024                // 0-based, so Some(0) legitimately means "after frame 0".
3025                session.validate()?;
3026                validate_operator_terminal_limit(*limit)
3027            }
3028            Self::SpawnSession { node_id, workspace_id, provider, provider_profile, terminal_size, .. } => {
3029                validate_node_workspace_route(node_id, workspace_id)?;
3030                if !valid_runtime_id(provider, 128) || !valid_runtime_id(provider_profile, 128) {
3031                    return Err(HarnessOperatorApiError::InvalidSessionSpawn);
3032                }
3033                if terminal_size.rows == 0 || terminal_size.columns == 0 {
3034                    return Err(HarnessOperatorApiError::InvalidSessionSpawn);
3035                }
3036                Ok(())
3037            }
3038            Self::WriteSessionInput { session, text } => {
3039                session.validate()?;
3040                if text.len() > HARNESS_SESSION_INPUT_MAX_BYTES {
3041                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3042                }
3043                Ok(())
3044            }
3045            // Same bound as `WriteSessionInput` immediately above -- see
3046            // `PromptSession`'s own doc comment for why this is a separate
3047            // verb rather than a flag on that one, and why the PTY-transport
3048            // refusal it also carries lives in `hatchery-harness-service`
3049            // rather than here: this type alone cannot see the target
3050            // session's transport.
3051            Self::PromptSession { session, text } => {
3052                session.validate()?;
3053                if text.len() > HARNESS_SESSION_INPUT_MAX_BYTES {
3054                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3055                }
3056                Ok(())
3057            }
3058            Self::ResizeSession { session, terminal_size } => {
3059                session.validate()?;
3060                if terminal_size.rows == 0 || terminal_size.columns == 0 {
3061                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3062                }
3063                Ok(())
3064            }
3065            Self::StopSession { session, .. } => session.validate(),
3066            Self::ControlSession { session, .. } => session.validate(),
3067            Self::WriteSessionBytes { session, bytes } => {
3068                session.validate()?;
3069                if bytes.is_empty() || bytes.len() > HARNESS_SESSION_BYTES_MAX_BYTES {
3070                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3071                }
3072                Ok(())
3073            }
3074            Self::PasteSession { session, text } => {
3075                session.validate()?;
3076                if text.len() > HARNESS_SESSION_INPUT_MAX_BYTES {
3077                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3078                }
3079                Ok(())
3080            }
3081            Self::RemoveSession { session } => session.validate(),
3082            Self::ResumeSession { session, terminal_size } => {
3083                session.validate()?;
3084                if terminal_size.rows == 0 || terminal_size.columns == 0 {
3085                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3086                }
3087                Ok(())
3088            }
3089            Self::ResolveInteraction { session, correlation_id, response } => {
3090                session.validate()?;
3091                if !valid_agent_stream_correlation_id(correlation_id) {
3092                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3093                }
3094                response.validate()
3095            }
3096            Self::SetSessionMode { session, mode_id } => {
3097                session.validate()?;
3098                if !valid_agent_stream_id(mode_id) {
3099                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3100                }
3101                Ok(())
3102            }
3103            Self::SetSessionConfigOption { session, option_id, value_json } => {
3104                session.validate()?;
3105                if !valid_agent_stream_id(option_id)
3106                    || value_json.is_empty()
3107                    || value_json.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3108                    || contains_unsafe_control_bytes(value_json)
3109                {
3110                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3111                }
3112                Ok(())
3113            }
3114            Self::SetSessionModel { session, model_id } => {
3115                session.validate()?;
3116                if !valid_agent_stream_id(model_id) {
3117                    return Err(HarnessOperatorApiError::InvalidSessionControl);
3118                }
3119                Ok(())
3120            }
3121            Self::CatalogNativeSessions { route, limit } => {
3122                route.validate()?;
3123                validate_native_session_catalog_limit(*limit)
3124            }
3125            Self::PageNativeSessions {
3126                route,
3127                catalog_revision,
3128                after_selection_id,
3129                limit,
3130                ..
3131            } => {
3132                route.validate()?;
3133                if *catalog_revision == 0
3134                    || after_selection_id.as_deref().is_some_and(|value| {
3135                        !valid_native_selection_id(value)
3136                    })
3137                {
3138                    return Err(HarnessOperatorApiError::InvalidNativeHistory);
3139                }
3140                validate_native_session_catalog_limit(*limit)
3141            }
3142            Self::PreviewNativeSession { selection, message_limit } => {
3143                selection.validate()?;
3144                if !(1..=HARNESS_NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
3145                    .contains(message_limit)
3146                {
3147                    return Err(HarnessOperatorApiError::InvalidLimit);
3148                }
3149                Ok(())
3150            }
3151            Self::PreviewSessionRecord { node_id, record_id, message_limit } => {
3152                if !valid_runtime_id(node_id, 128) || !valid_runtime_id(record_id, 128) {
3153                    return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
3154                }
3155                if !(1..=HARNESS_NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
3156                    .contains(message_limit)
3157                {
3158                    return Err(HarnessOperatorApiError::InvalidLimit);
3159                }
3160                Ok(())
3161            }
3162            Self::ResumeSessionRecord { node_id, record_id, terminal_size, initial_prompt } => {
3163                if !valid_runtime_id(node_id, 128)
3164                    || !valid_runtime_id(record_id, 128)
3165                    || terminal_size.rows == 0
3166                    || terminal_size.columns == 0
3167                {
3168                    return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
3169                }
3170                if initial_prompt.as_deref().is_some_and(|prompt| {
3171                    prompt.is_empty() || prompt.len() > HARNESS_BODY_MAX_BYTES
3172                }) {
3173                    return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
3174                }
3175                Ok(())
3176            }
3177            Self::RenameSessionRecord { node_id, record_id, display_name } => {
3178                if !valid_runtime_id(node_id, 128)
3179                    || !valid_runtime_id(record_id, 128)
3180                    || display_name.is_empty()
3181                    || display_name.len() > HARNESS_SESSION_RECORD_DISPLAY_NAME_MAX_BYTES
3182                    || display_name.chars().any(char::is_control)
3183                {
3184                    return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
3185                }
3186                Ok(())
3187            }
3188            Self::SetSessionTask { node_id, record_id, target, .. } => {
3189                if !valid_runtime_id(node_id, 128) || !valid_runtime_id(record_id, 128) {
3190                    return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
3191                }
3192                target.validate()
3193            }
3194            Self::ForgetSessionRecord { node_id, record_id } => {
3195                if !valid_runtime_id(node_id, 128) || !valid_runtime_id(record_id, 128) {
3196                    return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
3197                }
3198                Ok(())
3199            }
3200            Self::IndexProviderSession { node_id, workspace_id, provider, identity, display_name } => {
3201                if !valid_runtime_id(node_id, 128)
3202                    || !valid_runtime_id(workspace_id, 128)
3203                    || !valid_runtime_id(provider, 128)
3204                    || display_name.is_empty()
3205                    || display_name.len() > HARNESS_SESSION_RECORD_DISPLAY_NAME_MAX_BYTES
3206                    || display_name.chars().any(char::is_control)
3207                {
3208                    return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
3209                }
3210                identity.validate()
3211            }
3212            Self::IndexNativeSession { selection, display_name } => {
3213                selection.validate()?;
3214                if display_name.is_empty()
3215                    || display_name.len() > HARNESS_SESSION_RECORD_DISPLAY_NAME_MAX_BYTES
3216                    || display_name.chars().any(char::is_control)
3217                {
3218                    return Err(HarnessOperatorApiError::InvalidSessionRecordRequest);
3219                }
3220                Ok(())
3221            }
3222            Self::CreateTask { request } => {
3223                request.validate().map_err(HarnessOperatorApiError::Protocol)
3224            }
3225            Self::ReplaceTask { request } => {
3226                request.validate().map_err(HarnessOperatorApiError::Protocol)
3227            }
3228            Self::MoveTask { request } => {
3229                request.validate().map_err(HarnessOperatorApiError::Protocol)
3230            }
3231            Self::CancelTask { request } => {
3232                request.validate().map_err(HarnessOperatorApiError::Protocol)
3233            }
3234            Self::RetryTask { request } => {
3235                request.validate().map_err(HarnessOperatorApiError::Protocol)
3236            }
3237            Self::ScheduleNext { request } => {
3238                request.validate().map_err(HarnessOperatorApiError::Protocol)
3239            }
3240            Self::ReplaceTaskExecutionSpec { request } => {
3241                request.validate().map_err(HarnessOperatorApiError::Protocol)
3242            }
3243            Self::StartTask { request } => {
3244                request.validate().map_err(HarnessOperatorApiError::Protocol)
3245            }
3246            Self::ReplaceTaskExecutionSpecV2 { request } => request.validate(),
3247            Self::StartTaskV2 { request } => request.validate(),
3248            Self::MintHarnessMcpGrant { request } => request.validate(),
3249            Self::SubmitIntent { intent } => intent.validate(),
3250            Self::BrowseHostDirectories { node_id, directory, after } => {
3251                if !valid_runtime_id(node_id, 128) {
3252                    return Err(HarnessOperatorApiError::InvalidHostDirectoryBrowseRequest);
3253                }
3254                if let Some(directory) = directory { directory.validate()?; }
3255                if let Some(after) = after { after.validate()?; }
3256                Ok(())
3257            }
3258            Self::RegisterWorkspace { node_id, workspace_id, root } => {
3259                if !valid_runtime_id(node_id, 128) || !valid_runtime_id(workspace_id, 128) {
3260                    return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
3261                }
3262                root.validate()
3263            }
3264            Self::UnregisterWorkspace { node_id, workspace_id } => {
3265                if !valid_runtime_id(node_id, 128) || !valid_runtime_id(workspace_id, 128) {
3266                    return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
3267                }
3268                Ok(())
3269            }
3270            Self::CreateStandaloneWorkspace { node_id, workspace_id, root, initial_branch } => {
3271                if !valid_runtime_id(node_id, 128) || !valid_runtime_id(workspace_id, 128) {
3272                    return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
3273                }
3274                root.validate()?;
3275                if let Some(branch) = initial_branch {
3276                    if !valid_native_single_line(branch, HARNESS_REPOSITORY_PATH_MAX_BYTES, true) {
3277                        return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
3278                    }
3279                }
3280                Ok(())
3281            }
3282            Self::CreateWorktree {
3283                node_id, source_workspace_id, workspace_id, target_root, branch, base,
3284            } => {
3285                if !valid_runtime_id(node_id, 128)
3286                    || !valid_runtime_id(source_workspace_id, 128)
3287                    || !valid_runtime_id(workspace_id, 128)
3288                    || !valid_native_single_line(branch, HARNESS_REPOSITORY_PATH_MAX_BYTES, true)
3289                {
3290                    return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
3291                }
3292                if let Some(base) = base {
3293                    if !valid_native_single_line(base, HARNESS_REPOSITORY_PATH_MAX_BYTES, true) {
3294                        return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
3295                    }
3296                }
3297                target_root.validate()
3298            }
3299            Self::RemoveWorktree { node_id, source_workspace_id, target_root } => {
3300                if !valid_runtime_id(node_id, 128) || !valid_runtime_id(source_workspace_id, 128) {
3301                    return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
3302                }
3303                target_root.validate()
3304            }
3305            Self::ExportContextPack { session } => session.validate(),
3306            Self::ForgetContextPack { node_id, context_id } => {
3307                if !valid_runtime_id(node_id, 128) {
3308                    return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
3309                }
3310                context_id.validate().map_err(HarnessOperatorApiError::Protocol)
3311            }
3312            Self::SubscribeEvents {} => Ok(()),
3313            Self::SubscribeTerminal { sessions } => {
3314                if sessions.is_empty()
3315                    || sessions.len() > HARNESS_TERMINAL_SUBSCRIPTION_SESSIONS_MAX
3316                {
3317                    return Err(HarnessOperatorApiError::InvalidTerminalPage);
3318                }
3319                for session in sessions { session.validate()?; }
3320                let mut seen = std::collections::HashSet::with_capacity(sessions.len());
3321                if !sessions.iter().all(|session| seen.insert(session)) {
3322                    return Err(HarnessOperatorApiError::InvalidTerminalPage);
3323                }
3324                Ok(())
3325            }
3326            Self::SubscribeAgentStream { sessions } => {
3327                if sessions.is_empty()
3328                    || sessions.len() > HARNESS_AGENT_STREAM_SUBSCRIPTION_SESSIONS_MAX
3329                {
3330                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3331                }
3332                for session in sessions { session.validate()?; }
3333                let mut seen = std::collections::HashSet::with_capacity(sessions.len());
3334                if !sessions.iter().all(|session| seen.insert(session)) {
3335                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3336                }
3337                Ok(())
3338            }
3339        }
3340    }
3341
3342}
3343
3344#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3345#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
3346pub enum HarnessOperatorReplyV1 {
3347    Ok { response: HarnessOperatorResponseV1 },
3348    Error { error: HarnessOperatorHostErrorV1 },
3349}
3350
3351impl HarnessOperatorReplyV1 {
3352    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
3353        match self {
3354            Self::Ok { response } => response.validate(),
3355            Self::Error { .. } => Ok(()),
3356        }
3357    }
3358}
3359
3360/// Long-lived, server-push counterpart to `HarnessOperatorReplyV1`. A
3361/// `SubscribeEvents` connection stops replying with a single frame and
3362/// instead writes one of these, newline-terminated, per change -- see the
3363/// module doc for the framing this switches the connection into.
3364/// `sequence` is a per-subscription monotonic counter the host mints for
3365/// that one connection alone; it never resets, including across a
3366/// `Lagged`/`SnapshotBaseline` pair, so a client can detect a gap without
3367/// cross-referencing wall time. An auth failure or an over-limit subscribe
3368/// never reaches this type at all -- both still use the ordinary
3369/// single-frame `HarnessOperatorReplyV1::Error` before the connection
3370/// closes, since neither one ever admits a subscription in the first place.
3371#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3372#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
3373pub enum HarnessOperatorEventV1 {
3374    /// Always the first frame after a successful subscribe, and re-sent as
3375    /// the recovery frame immediately after a `Lagged` -- the client must
3376    /// treat this as a full replacement of its task/run/node state, never a
3377    /// delta against whatever it held before.
3378    SnapshotBaseline {
3379        sequence: u64,
3380        tasks: Vec<RedactedTaskV1>,
3381        runs: Vec<RedactedRunV1>,
3382        nodes: Vec<HarnessRuntimeNodeInventoryV1>,
3383    },
3384    TaskChanged { sequence: u64, task: RedactedTaskV1 },
3385    RunChanged { sequence: u64, run: RedactedRunV1 },
3386    RuntimeInventoryChanged { sequence: u64, node: HarnessRuntimeNodeInventoryV1 },
3387    RuntimeInventoryRemoved { sequence: u64, node_id: String },
3388    /// This subscriber's outbound queue overflowed and one or more events
3389    /// were dropped for it specifically -- other subscribers are unaffected.
3390    /// The host always follows this with a fresh `SnapshotBaseline` once it
3391    /// can build one; until that frame arrives the client must treat every
3392    /// task/run/node it is holding as stale rather than trying to patch
3393    /// around the gap.
3394    Lagged { sequence: u64 },
3395    /// Server-side keep-alive, unconditional and periodic (see
3396    /// `hatchery-harness-service::runtime`'s own
3397    /// `HOST_SUBSCRIBER_KEEPALIVE_INTERVAL`): the registry's only way to
3398    /// discover a dead subscriber is a write to it actually failing, so
3399    /// without something to push on an otherwise-idle connection an
3400    /// abandoned subscriber can sit occupying its slot indefinitely (see
3401    /// `SubscriberRegistry`'s own doc comment). Deliberately its own
3402    /// variant rather than reusing an existing one: `Lagged` carries a
3403    /// promise (a `SnapshotBaseline` follows) that a bare keep-alive would
3404    /// break, and every other variant carries real task/run/node state a
3405    /// client applies -- faking either risks a client acting on a change
3406    /// that never happened. `Ping` carries nothing to act on, so it cannot
3407    /// be mistaken for either; every current reader
3408    /// (`hatchery-harness-client`'s generic `next_event` decode,
3409    /// `hatchery-tui`'s `project_harness_operator_event`) drops it on the
3410    /// floor by construction. `sequence` is kept only for schema symmetry
3411    /// with every other variant -- there is no gap to detect against a
3412    /// keep-alive that carries no state of its own.
3413    Ping { sequence: u64 },
3414}
3415
3416impl HarnessOperatorEventV1 {
3417    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
3418        match self {
3419            Self::SnapshotBaseline { tasks, runs, nodes, .. } => {
3420                for task in tasks { task.validate().map_err(HarnessOperatorApiError::Read)?; }
3421                if tasks.windows(2).any(|pair| pair[0].task_id >= pair[1].task_id) {
3422                    return Err(HarnessOperatorApiError::Read(HarnessReadApiError::InvalidCollection));
3423                }
3424                for run in runs { run.validate().map_err(HarnessOperatorApiError::Read)?; }
3425                if runs.windows(2).any(|pair| pair[0].run_id >= pair[1].run_id) {
3426                    return Err(HarnessOperatorApiError::Read(HarnessReadApiError::InvalidCollection));
3427                }
3428                for node in nodes { node.validate()?; }
3429                if nodes.windows(2).any(|pair| pair[0].node_id >= pair[1].node_id) {
3430                    return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
3431                }
3432                Ok(())
3433            }
3434            Self::TaskChanged { task, .. } => task.validate().map_err(HarnessOperatorApiError::Read),
3435            Self::RunChanged { run, .. } => run.validate().map_err(HarnessOperatorApiError::Read),
3436            Self::RuntimeInventoryChanged { node, .. } => node.validate(),
3437            Self::RuntimeInventoryRemoved { node_id, .. } => {
3438                if !valid_runtime_id(node_id, 128) {
3439                    return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
3440                }
3441                Ok(())
3442            }
3443            Self::Lagged { .. } => Ok(()),
3444            Self::Ping { .. } => Ok(()),
3445        }
3446    }
3447}
3448
3449/// Long-lived, server-push counterpart to a `SubscribeTerminal` connection --
3450/// see `HarnessOperatorEventV1`'s own doc comment for the shared framing this
3451/// also uses. Deliberately its own type, not a variant of
3452/// `HarnessOperatorEventV1`: that type's `Lagged`/`SnapshotBaseline` pair
3453/// promises a full task/run/node resync on overflow, which is both wrong for
3454/// a terminal frame (there is no "task/run/node" to resync, only a screen
3455/// that is already self-contained) and unaffordable at terminal-frame volume
3456/// (see `TerminalSubscriberRegistry`'s own doc comment for the coalescing
3457/// discipline this type's `coalesced_since_last` field reports on).
3458#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3459#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
3460pub enum HarnessOperatorTerminalEventV1 {
3461    /// Sent once per session immediately after a successful subscribe (the
3462    /// tail of whatever `TerminalBufferRegistry` already holds, or nothing
3463    /// if the ring is empty for that session -- a legitimate state for a
3464    /// pane with no output yet), and again every time that session's ring
3465    /// gains a strictly newer frame. Always a full screen, never a delta
3466    /// (see backlog item 4) -- a client that missed intermediate frames is
3467    /// already caught up the moment this one arrives.
3468    TerminalFrame {
3469        sequence: u64,
3470        session: HarnessRuntimeSessionAddressV1,
3471        frame: HarnessRuntimeTerminalFrameV1,
3472        /// How many frames for THIS session were coalesced away (replaced
3473        /// before ever being sent) since the last frame actually delivered
3474        /// on this subscription. 0 on every frame delivered without
3475        /// contention, including the very first. Diagnostic only.
3476        coalesced_since_last: u32,
3477    },
3478    /// Same keep-alive rationale as `HarnessOperatorEventV1::Ping`: this is
3479    /// a physically separate connection with its own dead-peer-detection
3480    /// problem, independent of the task/run/node subscription's.
3481    Ping { sequence: u64 },
3482}
3483
3484impl HarnessOperatorTerminalEventV1 {
3485    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
3486        match self {
3487            Self::TerminalFrame { session, frame, .. } => {
3488                session.validate()?;
3489                frame.validate()
3490            }
3491            Self::Ping { .. } => Ok(()),
3492        }
3493    }
3494}
3495
3496/// One named catalog entry -- a selectable session mode or model on the
3497/// `ModeCatalog`/`ModelCatalog` chunk kinds. Exact mirror of
3498/// `gate4agent-node-protocol`'s own `AgentStreamNamedIdV1` (not a
3499/// `gate4agent_types` type -- it is minted on the node's own wire contract,
3500/// same duplication rationale as `HarnessTerminalControlV1`). `id` is what
3501/// `SetSessionMode`/`SetSessionModel` take back.
3502#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3503#[serde(deny_unknown_fields)]
3504pub struct HarnessAgentStreamNamedIdV1 {
3505    pub id: String,
3506    pub name: String,
3507    pub description: Option<String>,
3508}
3509
3510/// One selectable answer to an `InteractionPrompt` chunk -- an ACP
3511/// permission option, named by the provider rather than invented here.
3512/// Exact mirror of `gate4agent-node-protocol`'s own
3513/// `AgentStreamInteractionOptionV1`.
3514#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3515#[serde(deny_unknown_fields)]
3516pub struct HarnessAgentStreamInteractionOptionV1 {
3517    pub option_id: String,
3518    pub name: String,
3519    pub kind: String,
3520}
3521
3522/// Exact mirror of `gate4agent_types::ProviderConfigOptionKind`.
3523#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
3524#[serde(rename_all = "kebab-case")]
3525pub enum HarnessProviderConfigOptionKindV1 {
3526    Select,
3527    Boolean,
3528    Unknown,
3529}
3530
3531/// Exact mirror of `gate4agent_types::ProviderConfigChoice`.
3532#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3533#[serde(deny_unknown_fields)]
3534pub struct HarnessProviderConfigChoiceV1 {
3535    pub value_json: String,
3536    pub label: Option<String>,
3537}
3538
3539/// Exact mirror of `gate4agent_types::ProviderConfigOption`: the mechanism
3540/// ACP uses to change model, reasoning effort, and similar settings.
3541/// `ConfigOptions` always carries the FULL current set, never a delta --
3542/// same contract as the source type's own doc comment states for
3543/// `ProviderEvent::ConfigOptionsUpdated`.
3544#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3545#[serde(deny_unknown_fields)]
3546pub struct HarnessProviderConfigOptionV1 {
3547    pub id: String,
3548    pub name: String,
3549    pub description: Option<String>,
3550    pub category: Option<String>,
3551    pub kind: HarnessProviderConfigOptionKindV1,
3552    pub value_json: String,
3553    pub choices: Vec<HarnessProviderConfigChoiceV1>,
3554}
3555
3556/// Exact mirror of `hatchery_observation_protocol::BlockAuthorityV1`: this
3557/// crate has no dependency on `gate4agent-observation-protocol` (see the
3558/// doc comment on `HarnessTerminalControlV1`), so the WHO/WHAT-blocked-it
3559/// vocabulary `HarnessAgentStreamChunkKindV1::Blocked` carries is duplicated
3560/// here as its own closed wire enum rather than imported, the same way
3561/// `HarnessProviderInteractionKindV1` mirrors `gate4agent_types::
3562/// ProviderInteractionKind`.
3563#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
3564#[serde(rename_all = "kebab-case")]
3565pub enum HarnessBlockAuthorityV1 {
3566    HarnessGate,
3567    HarnessPolicy,
3568    HarnessDeadline,
3569    Operator,
3570    ProviderClassifier,
3571    ProviderPermissionRule,
3572    ProviderSandbox,
3573    ProviderRefusal,
3574    ProviderHook,
3575    UserRejected,
3576    /// Mirrors `hatchery_observation_protocol::BlockAuthorityV1::
3577    /// ProviderQuota` -- the provider's own account/plan quota, rate
3578    /// limit, or usage cap was exhausted.
3579    ProviderQuota,
3580    Unknown,
3581}
3582
3583/// The kind of a single `HarnessAgentStreamChunkV1` -- content the operator
3584/// needs to act on a running ACP session: what the agent is saying, a
3585/// pending interaction it needs answered, and the catalogs the three ACP
3586/// setter verbs (`SetSessionMode`, `SetSessionConfigOption`,
3587/// `SetSessionModel`) operate over. Exact mirror of
3588/// `gate4agent-node-protocol`'s own `AgentStreamChunkKindV1`, which itself
3589/// mirrors `gate4agent_types::ProviderEvent`'s content variants
3590/// deliberately, in contrast to the collapsed `ObservationKindV1` telemetry
3591/// stream, which carries none of it
3592/// (`docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
3593/// §3-4).
3594#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3595#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
3596pub enum HarnessAgentStreamChunkKindV1 {
3597    Text { text: String, is_delta: bool },
3598    Thinking { text: String },
3599    /// The same `correlation_id` `ObservationKindV1::ApprovalRequested`/
3600    /// `QuestionRequested` already minted, plus the question and option
3601    /// list `ResolveInteraction` answers blind without.
3602    InteractionPrompt {
3603        correlation_id: String,
3604        interaction_kind: HarnessProviderInteractionKindV1,
3605        tool_name: String,
3606        title: Option<String>,
3607        prompt: String,
3608        options: Vec<HarnessAgentStreamInteractionOptionV1>,
3609    },
3610    ModeCatalog {
3611        current: Option<String>,
3612        available: Vec<HarnessAgentStreamNamedIdV1>,
3613    },
3614    ConfigOptions { options: Vec<HarnessProviderConfigOptionV1> },
3615    ModelCatalog {
3616        current: Option<String>,
3617        available: Vec<HarnessAgentStreamNamedIdV1>,
3618    },
3619    /// The chunk twin of `ObservationKindV1::ActionBlocked`, delivered on
3620    /// the agent content stream instead of (never in place of) the
3621    /// observation channel -- see `gate4agent-node-protocol`'s own
3622    /// `AgentStreamChunkKindV1::Blocked` doc comment for why. An EVENT, not
3623    /// state: never seeded to a fresh subscriber, exactly like `Text`/
3624    /// `Thinking`.
3625    Blocked {
3626        correlation_id: Option<String>,
3627        tool_class: String,
3628        authority: HarnessBlockAuthorityV1,
3629        reason_kind: Option<String>,
3630        reason: String,
3631        help: Option<String>,
3632    },
3633}
3634
3635impl HarnessAgentStreamChunkKindV1 {
3636    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
3637        match self {
3638            Self::Text { text, .. } | Self::Thinking { text } => {
3639                if text.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3640                    || contains_unsafe_control_bytes(text)
3641                {
3642                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3643                }
3644                Ok(())
3645            }
3646            Self::InteractionPrompt { correlation_id, tool_name, title, prompt, options, .. } => {
3647                if !valid_agent_stream_correlation_id(correlation_id) {
3648                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3649                }
3650                if !valid_agent_stream_id(tool_name) {
3651                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3652                }
3653                if let Some(title) = title {
3654                    if title.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3655                        || contains_unsafe_control_bytes(title)
3656                    {
3657                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3658                    }
3659                }
3660                if prompt.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3661                    || contains_unsafe_control_bytes(prompt)
3662                {
3663                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3664                }
3665                if options.len() > HARNESS_AGENT_STREAM_INTERACTION_OPTIONS_MAX {
3666                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3667                }
3668                for option in options {
3669                    if !valid_agent_stream_id(&option.option_id)
3670                        || !valid_agent_stream_id(&option.name)
3671                        || !valid_agent_stream_id(&option.kind)
3672                    {
3673                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3674                    }
3675                }
3676                Ok(())
3677            }
3678            Self::ModeCatalog { current, available } | Self::ModelCatalog { current, available } => {
3679                if let Some(current) = current {
3680                    if !valid_agent_stream_id(current) {
3681                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3682                    }
3683                }
3684                validate_agent_stream_catalog_entries(available)
3685            }
3686            Self::ConfigOptions { options } => {
3687                if options.len() > HARNESS_AGENT_STREAM_CATALOG_ENTRIES_MAX {
3688                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3689                }
3690                for option in options {
3691                    if !valid_agent_stream_id(&option.id) || !valid_agent_stream_id(&option.name) {
3692                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3693                    }
3694                    if let Some(description) = &option.description {
3695                        if description.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3696                            || contains_unsafe_control_bytes(description)
3697                        {
3698                            return Err(HarnessOperatorApiError::InvalidAgentStream);
3699                        }
3700                    }
3701                    if let Some(category) = &option.category {
3702                        if !valid_agent_stream_id(category) {
3703                            return Err(HarnessOperatorApiError::InvalidAgentStream);
3704                        }
3705                    }
3706                    if option.value_json.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3707                        || contains_unsafe_control_bytes(&option.value_json)
3708                    {
3709                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3710                    }
3711                    if option.choices.len() > HARNESS_AGENT_STREAM_INTERACTION_OPTIONS_MAX {
3712                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3713                    }
3714                    for choice in &option.choices {
3715                        if choice.value_json.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3716                            || contains_unsafe_control_bytes(&choice.value_json)
3717                        {
3718                            return Err(HarnessOperatorApiError::InvalidAgentStream);
3719                        }
3720                        if let Some(label) = &choice.label {
3721                            if label.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3722                                || contains_unsafe_control_bytes(label)
3723                            {
3724                                return Err(HarnessOperatorApiError::InvalidAgentStream);
3725                            }
3726                        }
3727                    }
3728                }
3729                Ok(())
3730            }
3731            Self::Blocked { correlation_id, tool_class, reason_kind, reason, help, .. } => {
3732                if let Some(correlation_id) = correlation_id {
3733                    if !valid_agent_stream_correlation_id(correlation_id) {
3734                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3735                    }
3736                }
3737                if !valid_agent_stream_id(tool_class) {
3738                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3739                }
3740                if let Some(reason_kind) = reason_kind {
3741                    if !valid_agent_stream_id(reason_kind) {
3742                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3743                    }
3744                }
3745                if reason.is_empty()
3746                    || reason.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3747                    || contains_unsafe_control_bytes(reason)
3748                {
3749                    return Err(HarnessOperatorApiError::InvalidAgentStream);
3750                }
3751                if let Some(help) = help {
3752                    if help.is_empty()
3753                        || help.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3754                        || contains_unsafe_control_bytes(help)
3755                    {
3756                        return Err(HarnessOperatorApiError::InvalidAgentStream);
3757                    }
3758                }
3759                Ok(())
3760            }
3761        }
3762    }
3763}
3764
3765fn validate_agent_stream_catalog_entries(
3766    entries: &[HarnessAgentStreamNamedIdV1],
3767) -> Result<(), HarnessOperatorApiError> {
3768    if entries.len() > HARNESS_AGENT_STREAM_CATALOG_ENTRIES_MAX {
3769        return Err(HarnessOperatorApiError::InvalidAgentStream);
3770    }
3771    for entry in entries {
3772        if !valid_agent_stream_id(&entry.id) || !valid_agent_stream_id(&entry.name) {
3773            return Err(HarnessOperatorApiError::InvalidAgentStream);
3774        }
3775        if let Some(description) = &entry.description {
3776            if description.len() > HARNESS_AGENT_STREAM_TEXT_MAX_BYTES
3777                || contains_unsafe_control_bytes(description)
3778            {
3779                return Err(HarnessOperatorApiError::InvalidAgentStream);
3780            }
3781        }
3782    }
3783    Ok(())
3784}
3785
3786/// One chunk of the outbound agent content stream -- the operator-wire
3787/// counterpart of `gate4agent-node-protocol`'s `AgentStreamChunkV1`, itself
3788/// mirroring `NodeEvent::TerminalFrame`'s own precedent: its own
3789/// subscription, its own type, no `ObservationV1` resync promise (see
3790/// `HarnessOperatorAgentEventV1`'s doc comment below). `source_sequence`
3791/// orders chunks within one provider source the way
3792/// `ObservationV1::source_sequence` orders observations.
3793#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3794#[serde(deny_unknown_fields)]
3795pub struct HarnessAgentStreamChunkV1 {
3796    pub source_sequence: u64,
3797    pub kind: HarnessAgentStreamChunkKindV1,
3798}
3799
3800impl HarnessAgentStreamChunkV1 {
3801    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
3802        self.kind.validate()
3803    }
3804}
3805
3806/// Long-lived, server-push counterpart to a `SubscribeAgentStream`
3807/// connection -- see `HarnessOperatorEventV1`'s own doc comment for the
3808/// shared framing this also uses. Deliberately its own type, not a variant
3809/// of `HarnessOperatorEventV1` and not a variant of
3810/// `HarnessOperatorTerminalEventV1` either, for two separate reasons this
3811/// stream's overflow story below has to hold at once:
3812///
3813/// - Like `HarnessOperatorTerminalEventV1`, `HarnessOperatorEventV1`'s
3814///   `Lagged`/`SnapshotBaseline` pair is wrong here: there is no
3815///   task/run/node to resync, only content that is already self-contained.
3816/// - Unlike `HarnessOperatorTerminalEventV1`, a dropped chunk here cannot be
3817///   waved off with a diagnostic counter either. A terminal frame is always
3818///   a full screen, so `TerminalFrame`'s own `coalesced_since_last` can
3819///   silently supersede a stale frame with a fresher one and lose nothing
3820///   real. An agent-stream chunk has no such property: a `Text`/`Thinking`
3821///   delta or an `InteractionPrompt` is a fact about one instant, not a
3822///   snapshot of the whole session, so a chunk this stream drops is gone --
3823///   there is no later chunk that already contains it, and dropping an
3824///   `InteractionPrompt` in particular would strand the operator unable to
3825///   ever answer a correlation id they never saw.
3826///
3827/// So overflow here gets its own honest shape: `Lagged` reports a real,
3828/// unrecoverable loss for one subscribed session -- no snapshot follows it,
3829/// because none could repair what was lost -- and the client's only correct
3830/// response is to surface that loss to the operator, not to wait for a
3831/// recovery frame that is never coming.
3832///
3833/// `ReplayBoundary` is a separate, narrower concession to the same
3834/// "instants are gone once missed" rule: a subscriber routinely registers
3835/// seconds after a session's first turn already started (an operator can
3836/// only address a session that has been spawned), and every `Text`/
3837/// `Thinking`/`Blocked` chunk published into that gap used to be gone for
3838/// good. The harness now keeps a small bounded ring of the most recent ones
3839/// per session and replays it once at subscribe time, immediately marked by
3840/// this event so it is never mistaken for live content -- see this field's
3841/// own doc comment on `AgentChunk::published_at_ms` for how a replayed
3842/// chunk keeps proving it is history, not now.
3843#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3844#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
3845pub enum HarnessOperatorAgentEventV1 {
3846    AgentChunk {
3847        sequence: u64,
3848        session: HarnessRuntimeSessionAddressV1,
3849        chunk: HarnessAgentStreamChunkV1,
3850        /// The harness's own wall-clock reading of the moment this chunk
3851        /// was first published to `AgentStreamSubscriberRegistry`
3852        /// (`hatchery-harness-service::agent_stream`), carried unchanged
3853        /// through every delivery path -- live, state seed, and the bounded
3854        /// replay `ReplayBoundary` marks the end of. A replayed chunk keeps
3855        /// the timestamp it was born with here; nothing ever restamps it to
3856        /// the moment it happened to be replayed.
3857        published_at_ms: u64,
3858    },
3859    /// This subscriber's outbound queue overflowed for the named session and
3860    /// `dropped` chunks were lost for it specifically -- other subscribers,
3861    /// and this subscriber's other subscribed sessions, are unaffected. See
3862    /// this type's own doc comment for why there is no recovery frame to
3863    /// follow it.
3864    Lagged {
3865        sequence: u64,
3866        session: HarnessRuntimeSessionAddressV1,
3867        dropped: u64,
3868    },
3869    /// Sent at most once per `SubscribeAgentStream` subscription per
3870    /// session, right after that session's replayed instant chunks
3871    /// (`Text`/`Thinking`/`Blocked` pulled from the harness's own bounded
3872    /// replay ring -- see `hatchery-harness-service::agent_stream`'s own
3873    /// doc comment) and before any live chunk for that session: the marker
3874    /// that lets the operator tell "recent history it missed" apart from
3875    /// "happening right now". Sent for every session the harness has
3876    /// recorded ANYTHING for at all (state, replay, or both), even if the
3877    /// ring itself turned out empty for it (`replayed: 0`) -- but never for
3878    /// a session the harness has not seen even once, since there is nothing
3879    /// to mark a boundary against there and the first thing that subscriber
3880    /// sees for it will just be live content, unambiguously so.
3881    /// `dropped_before_replay` is the ring's own running eviction count --
3882    /// nonzero here means the replay itself is truncated, and it names that
3883    /// gap rather than silently handing back a partial history as if it
3884    /// were the whole one.
3885    ReplayBoundary {
3886        session: HarnessRuntimeSessionAddressV1,
3887        replayed: u32,
3888        dropped_before_replay: u64,
3889    },
3890    /// Same keep-alive rationale as `HarnessOperatorTerminalEventV1::Ping`:
3891    /// this is a physically separate connection with its own dead-peer-
3892    /// detection problem, independent of the terminal and task/run/node
3893    /// subscriptions' own.
3894    Ping { sequence: u64 },
3895}
3896
3897impl HarnessOperatorAgentEventV1 {
3898    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
3899        match self {
3900            Self::AgentChunk { session, chunk, .. } => {
3901                session.validate()?;
3902                chunk.validate()
3903            }
3904            Self::Lagged { session, .. } => session.validate(),
3905            Self::ReplayBoundary { session, .. } => session.validate(),
3906            Self::Ping { .. } => Ok(()),
3907        }
3908    }
3909}
3910
3911#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3912#[serde(deny_unknown_fields)]
3913pub struct HarnessMcpGrantMintV1 {
3914    pub grant_id: SessionGrantId,
3915    pub revision: HarnessRevision,
3916    /// The same dispatch `StartTaskV2` returns. Present so the host can
3917    /// notify subscribers; `hatchery-harnessctl grant mint` does not print it.
3918    pub dispatch: HarnessDispatchIntentV1,
3919    pub replayed: bool,
3920}
3921
3922impl HarnessMcpGrantMintV1 {
3923    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
3924        self.grant_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
3925        self.revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
3926        self.dispatch.validate().map_err(HarnessOperatorApiError::Protocol)?;
3927        Ok(())
3928    }
3929}
3930
3931#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3932#[serde(tag = "kind", content = "value", rename_all = "kebab-case", deny_unknown_fields)]
3933pub enum HarnessOperatorResponseV1 {
3934    Monitor(SessionMonitorV1),
3935    Timeline(TimelinePageV1),
3936    Tasks(TaskPageV1),
3937    Task(RedactedTaskV1),
3938    TaskOperations(Vec<HarnessOperationLedgerEntryV1>),
3939    Runs(RunPageV1),
3940    Run(RedactedRunV1),
3941    RunCorrelation(HarnessRunCorrelationV1),
3942    RunTransfer(HarnessRunTransferSummaryV1),
3943    ReverseAttribution(HarnessReverseAttributionV1),
3944    RunContextSourceObserved(HarnessRunContextSourceObservationV1),
3945    RunWorkspaceInspected(HarnessRunWorkspaceInspectionV1),
3946    RunWorkspaceFileRead(HarnessRunWorkspaceFileV1),
3947    RunGitHistoryRead(HarnessRunGitHistoryPageV1),
3948    RunGitDiffRead(HarnessRunGitDiffV1),
3949    NodeWorkspaceInspected(HarnessNodeWorkspaceInspectionV1),
3950    NodeWorkspaceFileRead(HarnessNodeWorkspaceFileV1),
3951    NodeGitHistoryRead(HarnessNodeGitHistoryPageV1),
3952    NodeGitDiffRead(HarnessNodeGitDiffV1),
3953    // V11 write/create siblings of the three node-workspace read responses
3954    // above: same payload shapes the node's own `WorkspaceFileWritten`/
3955    // `WorkspaceFileCreated`/`WorkspaceDirectoryCreated` replies carry,
3956    // projected the same way `NodeWorkspaceFileRead` already is.
3957    NodeWorkspaceFileWritten(HarnessNodeWorkspaceFileV1),
3958    NodeWorkspaceFileCreated(HarnessNodeWorkspaceFileV1),
3959    NodeWorkspaceDirectoryCreated(HarnessNodeWorkspaceDirectoryV1),
3960    LaunchPlans(HarnessLaunchPlanPageV1),
3961    TaskExecutionSpec(Option<HarnessTaskExecutionSpecV1>),
3962    TaskLaunchOptions(HarnessTaskLaunchOptionsV1),
3963    RuntimeInventory(HarnessRuntimeInventoryPageV1),
3964    TerminalRead(HarnessRuntimeTerminalPageV1),
3965    SessionSpawned(HarnessRuntimeSessionAddressV1),
3966    SessionInputWritten,
3967    SessionResized,
3968    SessionStopped,
3969    SessionControlled,
3970    SessionBytesWritten,
3971    SessionPasted,
3972    SessionRemoved,
3973    SessionResumed,
3974    // `PromptSession`'s ack -- same bare shape as `SessionInputWritten`
3975    // above, because the node's own `NodeRequest::Prompt` handler acks with
3976    // a bare `NodeResponse::Accepted` too. See `HarnessOperatorRequestV1::
3977    // PromptSession`'s own doc comment.
3978    SessionPrompted,
3979    // Unit acks for the four ACP control verbs above -- same bare-ack shape
3980    // as the eight session-control acks immediately above, because the node
3981    // itself acks all four with its own bare `NodeResponse::Accepted`.
3982    InteractionResolved,
3983    SessionModeSet,
3984    SessionConfigOptionSet,
3985    SessionModelSet,
3986    NativeSessionsCataloged(HarnessNativeSessionsCatalogedV1),
3987    NativeSessionsPaged(HarnessNativeSessionsPagedV1),
3988    NativeSessionPreviewed(HarnessNativeSessionPreviewedV1),
3989    SessionRecordPreviewed(HarnessSessionRecordPreviewedV1),
3990    SessionRecordResumed(HarnessSessionRecordResumedV1),
3991    SessionRecordUpdated(HarnessRuntimeManagedSessionV1),
3992    SessionRecordForgotten { record_id: String },
3993    ProviderSessionIndexed(HarnessRuntimeManagedSessionV1),
3994    NativeSessionIndexed(HarnessNativeSessionIndexedV1),
3995    HostDirectoriesBrowsed(HarnessHostDirectoryListingV1),
3996    WorkspaceRegistered(HarnessWorkspaceSnapshotV1),
3997    StandaloneWorkspaceCreated(HarnessWorkspaceSnapshotV1),
3998    WorkspaceUnregistered { workspace_id: String },
3999    WorktreeCreated {
4000        worktree: HarnessGitWorktreeSnapshotV1,
4001        workspace: HarnessWorkspaceSnapshotV1,
4002    },
4003    WorktreeRemoved {
4004        target_root: HarnessHostPathV1,
4005        workspace_id: Option<String>,
4006    },
4007    ContextPackExported(HarnessResolvedContextPackReceiptV1),
4008    ContextPackForgotten { context_id: String },
4009    Mutation(HarnessOperatorMutationOutcomeV1),
4010    ExecutionSpecMutation(HarnessOperatorMutationOutcomeV1),
4011    Schedule(HarnessScheduleOutcomeV1),
4012    TaskStarted(HarnessTaskStartOutcomeV1),
4013    /// Grant id and revision from `resolve_harness_mcp_grant`. No bearer
4014    /// credential: `HarnessReadCredential`'s `Serialize` writes the raw
4015    /// token, and this command must not.
4016    HarnessMcpGrantMinted(HarnessMcpGrantMintV1),
4017}
4018
4019impl HarnessOperatorResponseV1 {
4020    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4021        match self {
4022            Self::Monitor(value) => value.validate().map_err(HarnessOperatorApiError::Read),
4023            Self::Timeline(value) => value.validate().map_err(HarnessOperatorApiError::Read),
4024            Self::Tasks(value) => value.validate().map_err(HarnessOperatorApiError::Read),
4025            Self::Task(value) => value.validate().map_err(HarnessOperatorApiError::Read),
4026            Self::TaskOperations(entries) => {
4027                if entries.len() > usize::from(HARNESS_ENTITY_PAGE_LIMIT_MAX) {
4028                    return Err(HarnessOperatorApiError::InvalidOperationLedger);
4029                }
4030                for entry in entries {
4031                    entry.validate()?;
4032                }
4033                Ok(())
4034            }
4035            Self::Runs(value) => value.validate().map_err(HarnessOperatorApiError::Read),
4036            Self::Run(value) => value.validate().map_err(HarnessOperatorApiError::Read),
4037            Self::RunCorrelation(value) => value.validate(),
4038            Self::RunTransfer(value) => value.validate(),
4039            Self::ReverseAttribution(value) => value.validate(),
4040            Self::RunContextSourceObserved(value) => value.validate(),
4041            Self::RunWorkspaceInspected(value) => value.validate(),
4042            Self::RunWorkspaceFileRead(value) => value.validate(),
4043            Self::RunGitHistoryRead(value) => value.validate(),
4044            Self::RunGitDiffRead(value) => value.validate(),
4045            Self::NodeWorkspaceInspected(value) => value.validate(),
4046            Self::NodeWorkspaceFileRead(value) => value.validate(),
4047            Self::NodeGitHistoryRead(value) => value.validate(),
4048            Self::NodeGitDiffRead(value) => value.validate(),
4049            Self::NodeWorkspaceFileWritten(value) | Self::NodeWorkspaceFileCreated(value) => {
4050                value.validate()
4051            }
4052            Self::NodeWorkspaceDirectoryCreated(value) => value.validate(),
4053            Self::LaunchPlans(value) => value.validate(),
4054            Self::TaskExecutionSpec(value) => {
4055                if let Some(value) = value {
4056                    value.validate().map_err(HarnessOperatorApiError::Protocol)?;
4057                }
4058                Ok(())
4059            }
4060            Self::TaskLaunchOptions(value) => value.validate(),
4061            Self::RuntimeInventory(value) => value.validate(),
4062            Self::TerminalRead(value) => value.validate(),
4063            Self::SessionSpawned(value) => value.validate(),
4064            Self::SessionInputWritten
4065            | Self::SessionResized
4066            | Self::SessionStopped
4067            | Self::SessionControlled
4068            | Self::SessionBytesWritten
4069            | Self::SessionPasted
4070            | Self::SessionRemoved
4071            | Self::SessionResumed
4072            | Self::SessionPrompted
4073            | Self::InteractionResolved
4074            | Self::SessionModeSet
4075            | Self::SessionConfigOptionSet
4076            | Self::SessionModelSet => Ok(()),
4077            Self::NativeSessionsCataloged(value) => value.validate(),
4078            Self::NativeSessionsPaged(value) => value.validate(),
4079            Self::NativeSessionPreviewed(value) => value.validate(),
4080            Self::SessionRecordPreviewed(value) => value.validate(),
4081            Self::SessionRecordResumed(value) => value.validate(),
4082            Self::SessionRecordUpdated(value) | Self::ProviderSessionIndexed(value) => {
4083                value.validate()
4084            }
4085            Self::SessionRecordForgotten { record_id } => {
4086                if !valid_runtime_id(record_id, 128) {
4087                    return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4088                }
4089                Ok(())
4090            }
4091            Self::NativeSessionIndexed(value) => value.validate(),
4092            Self::HostDirectoriesBrowsed(value) => value.validate(),
4093            Self::WorkspaceRegistered(value) | Self::StandaloneWorkspaceCreated(value) => {
4094                value.validate()
4095            }
4096            Self::WorkspaceUnregistered { workspace_id } => {
4097                if !valid_runtime_id(workspace_id, 128) {
4098                    return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
4099                }
4100                Ok(())
4101            }
4102            Self::WorktreeCreated { worktree, workspace } => {
4103                worktree.validate()?;
4104                workspace.validate()
4105            }
4106            Self::WorktreeRemoved { target_root, workspace_id } => {
4107                target_root.validate()?;
4108                if let Some(workspace_id) = workspace_id {
4109                    if !valid_runtime_id(workspace_id, 128) {
4110                        return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
4111                    }
4112                }
4113                Ok(())
4114            }
4115            Self::ContextPackExported(value) => {
4116                value.validate().map_err(HarnessOperatorApiError::Protocol)
4117            }
4118            Self::ContextPackForgotten { context_id } => {
4119                if !valid_runtime_id(context_id, 128) {
4120                    return Err(HarnessOperatorApiError::InvalidResourceMutationRequest);
4121                }
4122                Ok(())
4123            }
4124            Self::Mutation(_) | Self::ExecutionSpecMutation(_) => Ok(()),
4125            Self::Schedule(HarnessScheduleOutcomeV1::Idle) => Ok(()),
4126            Self::Schedule(HarnessScheduleOutcomeV1::Dispatch(value)) => {
4127                value.validate().map_err(HarnessOperatorApiError::Protocol)
4128            }
4129            Self::TaskStarted(value) => {
4130                value.validate().map_err(HarnessOperatorApiError::Protocol)
4131            }
4132            Self::HarnessMcpGrantMinted(value) => value.validate(),
4133        }
4134    }
4135}
4136
4137#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
4138#[serde(transparent)]
4139pub struct HarnessNodeIncarnationV1(HarnessSelectorV1);
4140
4141impl HarnessNodeIncarnationV1 {
4142    pub fn new(value: impl Into<String>) -> Result<Self, HarnessOperatorApiError> {
4143        let value = value.into();
4144        if value.len() != 32 || !value.bytes().all(is_lower_hex) {
4145            return Err(HarnessOperatorApiError::InvalidRunCorrelation);
4146        }
4147        HarnessSelectorV1::new(value)
4148            .map(Self)
4149            .map_err(HarnessOperatorApiError::Protocol)
4150    }
4151
4152    pub fn as_str(&self) -> &str { self.0.as_str() }
4153
4154    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4155        self.0.validate().map_err(HarnessOperatorApiError::Protocol)?;
4156        if self.0.as_str().len() != 32 || !self.0.as_str().bytes().all(is_lower_hex) {
4157            return Err(HarnessOperatorApiError::InvalidRunCorrelation);
4158        }
4159        Ok(())
4160    }
4161}
4162
4163impl<'de> Deserialize<'de> for HarnessNodeIncarnationV1 {
4164    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4165    where
4166        D: Deserializer<'de>,
4167    {
4168        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
4169    }
4170}
4171
4172#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4173#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
4174pub enum HarnessRunWorktreeViewV1 {
4175    Existing,
4176    Managed { worktree_ref: HarnessSelectorV1 },
4177}
4178
4179impl HarnessRunWorktreeViewV1 {
4180    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4181        match self {
4182            Self::Existing => Ok(()),
4183            Self::Managed { worktree_ref } => {
4184                worktree_ref.validate().map_err(HarnessOperatorApiError::Protocol)
4185            }
4186        }
4187    }
4188}
4189
4190#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4191#[serde(deny_unknown_fields)]
4192pub struct HarnessManagedRunSessionV1 {
4193    pub record_id: HarnessSelectorV1,
4194    pub active_session: Option<HarnessRuntimeIdentityV1>,
4195}
4196
4197impl HarnessManagedRunSessionV1 {
4198    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4199        self.record_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4200        if let Some(active_session) = &self.active_session {
4201            active_session.validate().map_err(HarnessOperatorApiError::Protocol)?;
4202        }
4203        Ok(())
4204    }
4205}
4206
4207#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4208#[serde(deny_unknown_fields)]
4209pub struct HarnessInlineRunSessionV1 {
4210    pub inline_ref: HarnessInlineRef,
4211}
4212
4213impl HarnessInlineRunSessionV1 {
4214    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4215        self.inline_ref.validate().map_err(HarnessOperatorApiError::Protocol)
4216    }
4217}
4218
4219#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4220#[serde(tag = "kind", content = "value", rename_all = "kebab-case", deny_unknown_fields)]
4221pub enum HarnessRunSessionViewV1 {
4222    Managed(HarnessManagedRunSessionV1),
4223    Inline(HarnessInlineRunSessionV1),
4224}
4225
4226impl HarnessRunSessionViewV1 {
4227    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4228        match self {
4229            Self::Managed(value) => value.validate(),
4230            Self::Inline(value) => value.validate(),
4231        }
4232    }
4233}
4234
4235#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4236#[serde(rename_all = "kebab-case")]
4237pub enum HarnessRunCorrelationAvailabilityV1 {
4238    Available,
4239    Dormant,
4240    Unavailable,
4241    NotObserved,
4242    StaleIncarnation,
4243}
4244
4245#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4246#[serde(deny_unknown_fields)]
4247pub struct HarnessRunCorrelationV1 {
4248    pub run_id: HarnessRunId,
4249    pub run_revision: HarnessRevision,
4250    pub task_id: HarnessTaskId,
4251    pub node_id: HarnessSelectorV1,
4252    pub node_incarnation_id: HarnessNodeIncarnationV1,
4253    pub workspace_id: HarnessSelectorV1,
4254    pub provider_profile: HarnessSelectorV1,
4255    pub mode: HarnessExecutionModeV1,
4256    pub worktree: HarnessRunWorktreeViewV1,
4257    pub session: HarnessRunSessionViewV1,
4258    pub availability: HarnessRunCorrelationAvailabilityV1,
4259    pub observed_at_unix_ms: Option<u64>,
4260}
4261
4262impl HarnessRunCorrelationV1 {
4263    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4264        self.run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4265        self.run_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
4266        self.task_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4267        self.node_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4268        self.node_incarnation_id.validate()?;
4269        self.workspace_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4270        self.provider_profile.validate().map_err(HarnessOperatorApiError::Protocol)?;
4271        self.worktree.validate()?;
4272        self.session.validate()?;
4273        if matches!(&self.session, HarnessRunSessionViewV1::Inline(_))
4274            && self.mode != HarnessExecutionModeV1::Inline
4275        {
4276            return Err(HarnessOperatorApiError::InvalidRunCorrelation);
4277        }
4278        if matches!(self.availability, HarnessRunCorrelationAvailabilityV1::NotObserved)
4279            != self.observed_at_unix_ms.is_none()
4280            || self.observed_at_unix_ms == Some(0)
4281        {
4282            return Err(HarnessOperatorApiError::InvalidRunCorrelation);
4283        }
4284        match (&self.session, self.availability) {
4285            (
4286                HarnessRunSessionViewV1::Managed(HarnessManagedRunSessionV1 {
4287                    active_session: Some(_),
4288                    ..
4289                }),
4290                HarnessRunCorrelationAvailabilityV1::Dormant,
4291            )
4292            | (
4293                HarnessRunSessionViewV1::Managed(HarnessManagedRunSessionV1 {
4294                    active_session: None,
4295                    ..
4296                }),
4297                HarnessRunCorrelationAvailabilityV1::Available,
4298            )
4299            | (
4300                HarnessRunSessionViewV1::Inline(_),
4301                HarnessRunCorrelationAvailabilityV1::Available
4302                    | HarnessRunCorrelationAvailabilityV1::Dormant,
4303            ) => Err(HarnessOperatorApiError::InvalidRunCorrelation),
4304            _ => Ok(()),
4305        }
4306    }
4307}
4308
4309#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4310#[serde(deny_unknown_fields)]
4311pub struct HarnessRunDeliveryTransferV1 {
4312    pub delivery_ref: HarnessDeliveryRef,
4313    pub revision: HarnessRevision,
4314    pub state: HarnessDeliveryStateV1,
4315    pub selector: HarnessSelectorV1,
4316    pub bundle_id: HarnessDeliveryBundleIdV1,
4317    pub bundle_revision: HarnessDeliveryBundleRevisionV1,
4318    pub bundle_digest: HarnessDeliveryBundleDigestV1,
4319    pub manifest_digest: HarnessDeliveryManifestDigestV2,
4320    pub receipt_ref: Option<HarnessReceiptRef>,
4321    pub created_at_unix_ms: u64,
4322    pub updated_at_unix_ms: u64,
4323    pub staged_at_unix_ms: Option<u64>,
4324    pub committed_at_unix_ms: Option<u64>,
4325}
4326
4327impl HarnessRunDeliveryTransferV1 {
4328    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4329        self.delivery_ref.validate().map_err(HarnessOperatorApiError::Protocol)?;
4330        self.revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
4331        self.selector.validate().map_err(HarnessOperatorApiError::Protocol)?;
4332        self.bundle_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4333        self.bundle_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
4334        self.bundle_digest.validate().map_err(HarnessOperatorApiError::Protocol)?;
4335        self.manifest_digest.validate().map_err(HarnessOperatorApiError::Protocol)?;
4336        if let Some(receipt_ref) = &self.receipt_ref {
4337            receipt_ref.validate().map_err(HarnessOperatorApiError::Protocol)?;
4338        }
4339        if !valid_transfer_timestamps(
4340            self.created_at_unix_ms,
4341            self.updated_at_unix_ms,
4342            [self.staged_at_unix_ms, self.committed_at_unix_ms],
4343        ) {
4344            return Err(HarnessOperatorApiError::InvalidRunTransfer);
4345        }
4346        let state_fields = match self.state {
4347            HarnessDeliveryStateV1::Prepared => {
4348                self.staged_at_unix_ms.is_none()
4349                    && self.committed_at_unix_ms.is_none()
4350                    && self.receipt_ref.is_none()
4351            }
4352            HarnessDeliveryStateV1::Staged => {
4353                self.staged_at_unix_ms.is_some()
4354                    && self.committed_at_unix_ms.is_none()
4355                    && self.receipt_ref.is_none()
4356            }
4357            HarnessDeliveryStateV1::Committed => {
4358                self.staged_at_unix_ms.is_some()
4359                    && self.committed_at_unix_ms.is_some()
4360                    && self.receipt_ref.is_some()
4361            }
4362        };
4363        if !state_fields
4364            || matches!(
4365                (self.staged_at_unix_ms, self.committed_at_unix_ms),
4366                (Some(staged), Some(committed)) if committed < staged
4367            )
4368        {
4369            return Err(HarnessOperatorApiError::InvalidRunTransfer);
4370        }
4371        Ok(())
4372    }
4373}
4374
4375#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4376#[serde(deny_unknown_fields)]
4377pub struct HarnessRunContextTransferV1 {
4378    pub context_ref: HarnessSelectorV1,
4379    pub digest: String,
4380    pub source_message_count: u64,
4381    pub retained_message_count: u64,
4382    pub byte_len: u32,
4383    pub truncated: bool,
4384}
4385
4386impl HarnessRunContextTransferV1 {
4387    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4388        self.context_ref.validate().map_err(HarnessOperatorApiError::Protocol)?;
4389        let valid_digest = self.digest.strip_prefix("sha256:").is_some_and(|digest| {
4390            digest.len() == 64 && digest.bytes().all(is_lower_hex)
4391        });
4392        if !valid_digest
4393            || self.source_message_count == 0
4394            || self.retained_message_count == 0
4395            || self.retained_message_count > self.source_message_count
4396            || self.retained_message_count > HARNESS_CONTEXT_PACK_RETAINED_MESSAGES_MAX
4397            || self.byte_len == 0
4398            || self.byte_len > HARNESS_CONTEXT_PACK_MAX_BYTES
4399            || self.truncated != (self.source_message_count > self.retained_message_count)
4400        {
4401            return Err(HarnessOperatorApiError::InvalidRunTransfer);
4402        }
4403        Ok(())
4404    }
4405}
4406
4407#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4408#[serde(deny_unknown_fields)]
4409pub struct HarnessRunContinuationTransferV1 {
4410    pub continuation_ref: HarnessContinuationRef,
4411    pub receipt_ref: HarnessReceiptRef,
4412    pub revision: HarnessRevision,
4413    pub state: HarnessContinuationStateV1,
4414    pub source_run_id: HarnessRunId,
4415    pub target_run_id: HarnessRunId,
4416    pub source_provider: HarnessSelectorV1,
4417    pub context: Option<HarnessRunContextTransferV1>,
4418    pub prepared_at_unix_ms: u64,
4419    pub exporting_at_unix_ms: Option<u64>,
4420    pub exported_at_unix_ms: Option<u64>,
4421    pub bound_at_unix_ms: Option<u64>,
4422    pub expired_at_unix_ms: Option<u64>,
4423    pub outcome_unknown_at_unix_ms: Option<u64>,
4424    pub outcome_unknown_reason: Option<HarnessContinuationOutcomeUnknownReasonV1>,
4425    pub created_at_unix_ms: u64,
4426    pub updated_at_unix_ms: u64,
4427}
4428
4429impl HarnessRunContinuationTransferV1 {
4430    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4431        self.continuation_ref.validate().map_err(HarnessOperatorApiError::Protocol)?;
4432        self.receipt_ref.validate().map_err(HarnessOperatorApiError::Protocol)?;
4433        self.revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
4434        self.source_run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4435        self.target_run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4436        self.source_provider.validate().map_err(HarnessOperatorApiError::Protocol)?;
4437        if let Some(context) = &self.context { context.validate()?; }
4438        if self.source_run_id == self.target_run_id
4439            || self.prepared_at_unix_ms != self.created_at_unix_ms
4440            || !valid_transfer_timestamps(
4441                self.created_at_unix_ms,
4442                self.updated_at_unix_ms,
4443                [
4444                    Some(self.prepared_at_unix_ms),
4445                    self.exporting_at_unix_ms,
4446                    self.exported_at_unix_ms,
4447                    self.bound_at_unix_ms,
4448                    self.expired_at_unix_ms,
4449                    self.outcome_unknown_at_unix_ms,
4450                ],
4451            )
4452        {
4453            return Err(HarnessOperatorApiError::InvalidRunTransfer);
4454        }
4455        let ordered = self.exporting_at_unix_ms
4456            .is_none_or(|value| value >= self.prepared_at_unix_ms)
4457            && self.exported_at_unix_ms.is_none_or(|value| {
4458                self.exporting_at_unix_ms.is_some_and(|started| value >= started)
4459            })
4460            && self.bound_at_unix_ms.is_none_or(|value| {
4461                self.exported_at_unix_ms.is_some_and(|exported| value >= exported)
4462            })
4463            && self.outcome_unknown_at_unix_ms.is_none_or(|value| {
4464                self.exporting_at_unix_ms.is_some_and(|started| value >= started)
4465            })
4466            && self.expired_at_unix_ms
4467                .is_none_or(|value| value >= self.prepared_at_unix_ms);
4468        let state_fields = match self.state {
4469            HarnessContinuationStateV1::Prepared => self.context.is_none()
4470                && self.exporting_at_unix_ms.is_none()
4471                && self.exported_at_unix_ms.is_none()
4472                && self.bound_at_unix_ms.is_none()
4473                && self.expired_at_unix_ms.is_none()
4474                && self.outcome_unknown_at_unix_ms.is_none()
4475                && self.outcome_unknown_reason.is_none(),
4476            HarnessContinuationStateV1::Exporting => self.context.is_none()
4477                && self.exporting_at_unix_ms.is_some()
4478                && self.exported_at_unix_ms.is_none()
4479                && self.bound_at_unix_ms.is_none()
4480                && self.expired_at_unix_ms.is_none()
4481                && self.outcome_unknown_at_unix_ms.is_none()
4482                && self.outcome_unknown_reason.is_none(),
4483            HarnessContinuationStateV1::Exported => self.context.is_some()
4484                && self.exporting_at_unix_ms.is_some()
4485                && self.exported_at_unix_ms.is_some()
4486                && self.bound_at_unix_ms.is_none()
4487                && self.expired_at_unix_ms.is_none()
4488                && self.outcome_unknown_at_unix_ms.is_none()
4489                && self.outcome_unknown_reason.is_none(),
4490            HarnessContinuationStateV1::Bound => self.context.is_some()
4491                && self.exporting_at_unix_ms.is_some()
4492                && self.exported_at_unix_ms.is_some()
4493                && self.bound_at_unix_ms.is_some()
4494                && self.expired_at_unix_ms.is_none()
4495                && self.outcome_unknown_at_unix_ms.is_none()
4496                && self.outcome_unknown_reason.is_none(),
4497            HarnessContinuationStateV1::OutcomeUnknown => self.exporting_at_unix_ms.is_some()
4498                && self.exported_at_unix_ms.is_none()
4499                && self.bound_at_unix_ms.is_none()
4500                && self.expired_at_unix_ms.is_none()
4501                && self.outcome_unknown_at_unix_ms.is_some()
4502                && self.outcome_unknown_reason.is_some(),
4503            HarnessContinuationStateV1::Expired => self.bound_at_unix_ms.is_none()
4504                && self.expired_at_unix_ms.is_some()
4505                && self.outcome_unknown_at_unix_ms.is_none()
4506                && self.outcome_unknown_reason.is_none(),
4507        };
4508        if !ordered || !state_fields {
4509            return Err(HarnessOperatorApiError::InvalidRunTransfer);
4510        }
4511        Ok(())
4512    }
4513}
4514
4515#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4516#[serde(deny_unknown_fields)]
4517pub struct HarnessRunTransferSummaryV1 {
4518    pub run_id: HarnessRunId,
4519    pub run_revision: HarnessRevision,
4520    pub delivery: Option<HarnessRunDeliveryTransferV1>,
4521    pub continuation: Option<HarnessRunContinuationTransferV1>,
4522}
4523
4524impl HarnessRunTransferSummaryV1 {
4525    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4526        self.run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4527        self.run_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
4528        if let Some(delivery) = &self.delivery { delivery.validate()?; }
4529        if let Some(continuation) = &self.continuation {
4530            continuation.validate()?;
4531            if continuation.target_run_id != self.run_id {
4532                return Err(HarnessOperatorApiError::InvalidRunTransfer);
4533            }
4534        }
4535        Ok(())
4536    }
4537
4538    pub fn validate_for(&self, run_id: &HarnessRunId) -> Result<(), HarnessOperatorApiError> {
4539        self.validate()?;
4540        if &self.run_id != run_id {
4541            return Err(HarnessOperatorApiError::InvalidRunTransfer);
4542        }
4543        Ok(())
4544    }
4545}
4546
4547#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4548#[serde(deny_unknown_fields)]
4549pub struct HarnessRunContextSourceObservationV1 {
4550    pub run_id: HarnessRunId,
4551    pub run_revision: HarnessRevision,
4552    pub feature_state: FeatureObservationStateV1,
4553    pub message_count: u64,
4554    pub message_count_exact: bool,
4555    pub completed_turn_count: Option<u64>,
4556    pub total_tokens: Option<u64>,
4557    pub observed_at_unix_ms: Option<u64>,
4558}
4559
4560impl HarnessRunContextSourceObservationV1 {
4561    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4562        self.run_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
4563        self.run_revision.validate().map_err(HarnessOperatorApiError::Protocol)?;
4564        let observed = self.feature_state == FeatureObservationStateV1::Observed;
4565        let observed_fields_valid = self.message_count > 0
4566            && self.message_count_exact
4567            && self.observed_at_unix_ms.is_some_and(|timestamp| timestamp > 0)
4568            && self.completed_turn_count.map_or(true, |count| count <= self.message_count);
4569        let unobserved_fields_empty = self.message_count == 0
4570            && !self.message_count_exact
4571            && self.completed_turn_count.is_none()
4572            && self.total_tokens.is_none()
4573            && self.observed_at_unix_ms.is_none();
4574        if observed && !observed_fields_valid || !observed && !unobserved_fields_empty {
4575            return Err(HarnessOperatorApiError::InvalidRunContextSourceObservation);
4576        }
4577        Ok(())
4578    }
4579
4580    pub fn validate_for(&self, run_id: &HarnessRunId) -> Result<(), HarnessOperatorApiError> {
4581        self.validate()?;
4582        if &self.run_id != run_id {
4583            return Err(HarnessOperatorApiError::InvalidRunContextSourceObservation);
4584        }
4585        Ok(())
4586    }
4587}
4588
4589#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4590#[serde(deny_unknown_fields)]
4591pub struct HarnessRuntimeNodeInventoryV1 {
4592    pub node_id: String,
4593    pub incarnation_id: String,
4594    pub observed_at_unix_ms: u64,
4595    pub event_sequence: u64,
4596    pub inventory: HarnessRuntimeInventoryV1,
4597}
4598
4599impl HarnessRuntimeNodeInventoryV1 {
4600    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4601        if !valid_runtime_id(&self.node_id, 128)
4602            || self.incarnation_id.len() != 32
4603            || !self.incarnation_id.bytes().all(is_lower_hex)
4604            || self.observed_at_unix_ms == 0
4605        {
4606            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4607        }
4608        self.inventory.validate()
4609    }
4610}
4611
4612#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4613#[serde(deny_unknown_fields)]
4614pub struct HarnessRuntimeInventoryV1 {
4615    pub enabled_providers: Vec<String>,
4616    pub workspaces: BTreeMap<String, HarnessRuntimeWorkspaceV1>,
4617    pub workspace_count: usize,
4618    pub workspaces_truncated: bool,
4619    pub session_count: usize,
4620    pub sessions_truncated: bool,
4621    pub managed_sessions: Vec<HarnessRuntimeManagedSessionV1>,
4622    pub managed_session_count: usize,
4623    pub managed_sessions_truncated: bool,
4624    /// Lifetime count of managed session records the node's own retention
4625    /// sweep has retired (mirrors `hatchery_core::SlimNodeInventory::
4626    /// retired_count`). Additive and informational only, `#[serde(default)]`
4627    /// so a payload from before this field existed still deserializes.
4628    #[serde(default)]
4629    pub retired_count: usize,
4630    // Operator-visible surface: no redaction beyond what the direct-C2 TUI
4631    // already shows for the same node (`NodeView::launch_inventory`). Mirrors
4632    // `gate4agent_node_protocol::LaunchInventory` field-for-field rather than
4633    // reusing it: `gate4agent-node-protocol` already depends on this crate
4634    // (for the shared Harness MCP wire types), so the reverse edge would be
4635    // a cyclic package dependency. The TUI reconstructs the real node-protocol
4636    // type from this mirror on receipt (`client.rs::project_harness_inventory_node`).
4637    #[serde(default, skip_serializing_if = "Option::is_none")]
4638    pub launch_inventory: Option<HarnessRuntimeLaunchInventoryV1>,
4639}
4640
4641/// Same liveness-first rationale and exact rank table as c2-protocol's own
4642/// `managed_session_liveness_rank` (`hatchery_core::SlimNodeInventory`
4643/// page-cut ordering): `Live` ranks first, `IdentityPending` next, `Dormant`
4644/// next, terminal `Unavailable` last. The producer orders a page by this
4645/// rank before the `MAX_C2_MANAGED_SESSIONS_PER_NODE` cut, so a page is
4646/// never sorted by bare `record_id` alone -- validating strict ascending
4647/// `record_id` order regardless of state rejected every real page (fixed
4648/// after `ed4fa61` regressed this: see `managed_session_liveness_rank`'s
4649/// own doc comment in c2-protocol for why the producer's order changed).
4650fn managed_session_liveness_rank(state: &HarnessRuntimeManagedStateV1) -> u8 {
4651    match state {
4652        HarnessRuntimeManagedStateV1::Live => 0,
4653        HarnessRuntimeManagedStateV1::IdentityPending => 1,
4654        HarnessRuntimeManagedStateV1::Dormant => 2,
4655        HarnessRuntimeManagedStateV1::Unavailable => 3,
4656    }
4657}
4658
4659impl HarnessRuntimeInventoryV1 {
4660    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4661        let included_sessions = self.workspaces.values()
4662            .map(|workspace| workspace.sessions.len())
4663            .sum::<usize>();
4664        if self.enabled_providers.len() > 64
4665            || self.enabled_providers.windows(2).any(|pair| pair[0] >= pair[1])
4666            || self.enabled_providers.iter().any(|provider| !valid_runtime_id(provider, 128))
4667            || self.workspaces.len() > 32
4668            || included_sessions > 128
4669            || self.managed_sessions.len() > 128
4670            || self.workspace_count < self.workspaces.len()
4671            || self.session_count < included_sessions
4672            || self.managed_session_count < self.managed_sessions.len()
4673            || self.workspaces_truncated != (self.workspace_count > self.workspaces.len())
4674            || self.sessions_truncated != (self.session_count > included_sessions)
4675            || self.managed_sessions_truncated
4676                != (self.managed_session_count > self.managed_sessions.len())
4677            || self.managed_sessions.windows(2).any(|pair| {
4678                let left = (managed_session_liveness_rank(&pair[0].state), pair[0].record_id.as_str());
4679                let right = (managed_session_liveness_rank(&pair[1].state), pair[1].record_id.as_str());
4680                left >= right
4681            })
4682        {
4683            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4684        }
4685        for (workspace_id, workspace) in &self.workspaces {
4686            if workspace_id != &workspace.workspace_id { return Err(HarnessOperatorApiError::InvalidRuntimeInventory); }
4687            workspace.validate()?;
4688        }
4689        for record in &self.managed_sessions { record.validate()?; }
4690        if let Some(launch_inventory) = &self.launch_inventory { launch_inventory.validate()?; }
4691        Ok(())
4692    }
4693}
4694
4695/// Mirror of `gate4agent_node_protocol::ResolvedEnvironmentProfileReceipt`.
4696#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4697#[serde(deny_unknown_fields)]
4698pub struct HarnessRuntimeEnvironmentProfileReceiptV1 {
4699    pub profile_id: String,
4700    pub profile_revision: String,
4701}
4702
4703impl HarnessRuntimeEnvironmentProfileReceiptV1 {
4704    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4705        if !valid_runtime_id(&self.profile_id, 128) || !valid_runtime_id(&self.profile_revision, 128) {
4706            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4707        }
4708        Ok(())
4709    }
4710}
4711
4712/// Mirror of `gate4agent_node_protocol::SpawnProfileSummary`.
4713#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4714#[serde(deny_unknown_fields)]
4715pub struct HarnessRuntimeSpawnProfileSummaryV1 {
4716    pub id: String,
4717    pub revision: String,
4718    #[serde(default, skip_serializing_if = "Option::is_none")]
4719    pub environment_profile: Option<HarnessRuntimeEnvironmentProfileReceiptV1>,
4720}
4721
4722impl HarnessRuntimeSpawnProfileSummaryV1 {
4723    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4724        if !valid_runtime_id(&self.id, 128) || !valid_runtime_id(&self.revision, 128) {
4725            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4726        }
4727        if let Some(environment_profile) = &self.environment_profile {
4728            environment_profile.validate()?;
4729        }
4730        Ok(())
4731    }
4732}
4733
4734/// Mirror of `gate4agent_node_protocol::ResolvedBundleReceipt`.
4735#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4736#[serde(deny_unknown_fields)]
4737pub struct HarnessRuntimeBundleReceiptV1 {
4738    pub id: String,
4739    pub revision: String,
4740    pub digest: String,
4741}
4742
4743impl HarnessRuntimeBundleReceiptV1 {
4744    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4745        if !valid_runtime_id(&self.id, 128)
4746            || !valid_runtime_id(&self.revision, 128)
4747            || !valid_sha256_digest(&self.digest)
4748        {
4749            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4750        }
4751        Ok(())
4752    }
4753}
4754
4755/// Mirror of `gate4agent_node_protocol::LaunchInventory`.
4756#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4757#[serde(deny_unknown_fields)]
4758pub struct HarnessRuntimeLaunchInventoryV1 {
4759    #[serde(default, skip_serializing_if = "Option::is_none")]
4760    pub spawn_profiles: Option<Vec<HarnessRuntimeSpawnProfileSummaryV1>>,
4761    #[serde(default, skip_serializing_if = "Option::is_none")]
4762    pub bundles: Option<Vec<HarnessRuntimeBundleReceiptV1>>,
4763}
4764
4765impl HarnessRuntimeLaunchInventoryV1 {
4766    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4767        if self.spawn_profiles.is_none() && self.bundles.is_none() {
4768            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4769        }
4770        if let Some(profiles) = &self.spawn_profiles {
4771            if profiles.len() > HARNESS_RUNTIME_SPAWN_PROFILES_MAX {
4772                return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4773            }
4774            for profile in profiles { profile.validate()?; }
4775            if profiles.iter().enumerate().any(|(index, profile)| {
4776                profiles[..index].iter().any(|existing| existing.id == profile.id)
4777            }) {
4778                return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4779            }
4780        }
4781        if let Some(bundles) = &self.bundles {
4782            if bundles.len() > HARNESS_RUNTIME_LAUNCH_BUNDLES_MAX {
4783                return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4784            }
4785            for bundle in bundles { bundle.validate()?; }
4786        }
4787        Ok(())
4788    }
4789}
4790
4791#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4792#[serde(deny_unknown_fields)]
4793pub struct HarnessRuntimeWorkspaceV1 {
4794    pub workspace_id: String,
4795    pub display_root: String,
4796    pub display_root_truncated: bool,
4797    pub sessions: Vec<HarnessRuntimeSessionV1>,
4798    pub session_count: usize,
4799    pub sessions_truncated: bool,
4800}
4801
4802impl HarnessRuntimeWorkspaceV1 {
4803    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
4804        if !valid_runtime_id(&self.workspace_id, 128)
4805            || self.display_root.len() > 1024
4806            || self.display_root.chars().any(char::is_control)
4807            || self.sessions.len() > 128
4808            || self.session_count < self.sessions.len()
4809            || self.sessions_truncated != (self.session_count > self.sessions.len())
4810            || self.sessions.windows(2).any(|pair| {
4811                (pair[0].instance_id, pair[0].generation)
4812                    >= (pair[1].instance_id, pair[1].generation)
4813            })
4814        {
4815            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
4816        }
4817        for session in &self.sessions { session.validate()?; }
4818        Ok(())
4819    }
4820}
4821
4822#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4823#[serde(rename_all = "kebab-case")]
4824pub enum HarnessRuntimeTransportV1 { Pty, Pipe, Acp }
4825
4826#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4827#[serde(rename_all = "kebab-case")]
4828pub enum HarnessRuntimeSessionStatusV1 { Registered, Starting, Running, Stopping, Exited, Failed }
4829
4830/// Exact mirror of `gate4agent_types::TerminalControl`: this crate has no
4831/// dependency on `gate4agent-types` (see the doc comment on
4832/// `HARNESS_SESSION_BYTES_MAX_BYTES`), so `ControlSession`'s special-key
4833/// payload is duplicated here as its own closed wire enum rather than
4834/// imported. The variant set and names are kept in lockstep by hand --
4835/// `hatchery-harness-service`'s `c2::map_terminal_control` is the single
4836/// place that converts one into the other, so a variant added to one side
4837/// without the other fails to compile there. No `validate()` method: unlike
4838/// a bounded string or byte vector, a closed enum's own deserialization is
4839/// already the bound -- an unrecognized variant name is rejected before this
4840/// type is ever constructed.
4841#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4842#[serde(rename_all = "kebab-case")]
4843pub enum HarnessTerminalControlV1 {
4844    Interrupt,
4845    EndOfFile,
4846    ControlA,
4847    ControlB,
4848    ControlE,
4849    ControlF,
4850    ControlG,
4851    ControlH,
4852    ControlI,
4853    ControlJ,
4854    ControlK,
4855    ControlL,
4856    ControlM,
4857    ControlN,
4858    ControlO,
4859    ControlP,
4860    ControlQ,
4861    ControlR,
4862    ControlS,
4863    ControlT,
4864    ControlU,
4865    ControlV,
4866    ControlW,
4867    ControlX,
4868    ControlY,
4869    ControlZ,
4870    Enter,
4871    LineFeed,
4872    Escape,
4873    Backspace,
4874    Tab,
4875    BackTab,
4876    Insert,
4877    Delete,
4878    Home,
4879    End,
4880    PageUp,
4881    PageDown,
4882    ArrowUp,
4883    ArrowDown,
4884    ArrowRight,
4885    ArrowLeft,
4886    Function1,
4887    Function2,
4888    Function3,
4889    Function4,
4890    Function5,
4891    Function6,
4892    Function7,
4893    Function8,
4894    Function9,
4895    Function10,
4896    Function11,
4897    Function12,
4898}
4899
4900#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4901#[serde(deny_unknown_fields)]
4902pub struct HarnessRuntimeTerminalSizeV1 {
4903    pub rows: u16,
4904    pub columns: u16,
4905}
4906
4907/// Mirrors `gate4agent_types::FOREGROUND_PROCESS_NAME_MAX_BYTES`, the bound
4908/// on `PtyScreenStateV1::NotAgent`'s `observed_process`. Hand-kept in step
4909/// with the source constant for the same reason the enum itself is
4910/// hand-duplicated below.
4911pub const HARNESS_SCREEN_STATE_PROCESS_MAX_BYTES: usize = 512;
4912/// Mirrors `gate4agent_types::PTY_SCREEN_GATE_NAME_MAX_BYTES`, the bound on
4913/// `Failing`'s `reason`.
4914pub const HARNESS_SCREEN_STATE_GATE_MAX_BYTES: usize = 128;
4915/// Mirrors `gate4agent_types::OPERATOR_GATE_OPTIONS_MAX`.
4916pub const HARNESS_GATE_OPTIONS_MAX: usize = 8;
4917/// Mirrors `gate4agent_types::OPERATOR_GATE_OPTION_TEXT_MAX_BYTES`.
4918pub const HARNESS_GATE_OPTION_TEXT_MAX_BYTES: usize = 128;
4919/// Mirrors `gate4agent_types::OPERATOR_GATE_PATH_MAX_BYTES`.
4920pub const HARNESS_GATE_PATH_MAX_BYTES: usize = 32_768;
4921
4922/// Exact mirror of `gate4agent_types::OperatorGateKind`.
4923#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4924#[serde(rename_all = "kebab-case")]
4925pub enum OperatorGateKindV1 {
4926    WorkspaceTrust,
4927    HookTrust,
4928    Authentication,
4929    VendorUpdate,
4930    Onboarding,
4931    TerminalAppearance,
4932    ConfigurationMigration,
4933}
4934
4935impl OperatorGateKindV1 {
4936    /// Mirrors `gate4agent_types::OperatorGateKind::label`.
4937    pub fn label(&self) -> &'static str {
4938        match self {
4939            Self::WorkspaceTrust => "workspace trust",
4940            Self::HookTrust => "hook trust review",
4941            Self::Authentication => "authentication",
4942            Self::VendorUpdate => "vendor update",
4943            Self::Onboarding => "onboarding",
4944            Self::TerminalAppearance => "terminal appearance setup",
4945            Self::ConfigurationMigration => "configuration migration",
4946        }
4947    }
4948}
4949
4950/// Exact mirror of `gate4agent_types::OperatorGateSubject`.
4951#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4952#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
4953pub enum OperatorGateSubjectV1 {
4954    Directory { path: Option<String> },
4955    Hooks { count: Option<u32> },
4956    McpServers,
4957    Account,
4958    ApiKey,
4959    Appearance,
4960    Unknown,
4961}
4962
4963/// Exact mirror of `gate4agent_types::OperatorGateInput`.
4964#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4965#[serde(rename_all = "kebab-case")]
4966pub enum OperatorGateInputV1 {
4967    NumberedList,
4968    ArrowList,
4969    PressEnter,
4970    TextEntry,
4971    Unknown,
4972}
4973
4974/// Exact mirror of `gate4agent_types::OperatorGateOptionSemantics`.
4975#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4976#[serde(rename_all = "kebab-case")]
4977pub enum OperatorGateOptionSemanticsV1 {
4978    Accept,
4979    Decline,
4980    Inspect,
4981    Exit,
4982    Unknown,
4983}
4984
4985/// Exact mirror of `gate4agent_types::OperatorGateOption`.
4986#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4987#[serde(deny_unknown_fields)]
4988pub struct OperatorGateOptionV1 {
4989    pub text: String,
4990    pub semantics: OperatorGateOptionSemanticsV1,
4991    pub selected: bool,
4992}
4993
4994impl OperatorGateOptionV1 {
4995    fn is_valid(&self) -> bool {
4996        valid_native_single_line(&self.text, HARNESS_GATE_OPTION_TEXT_MAX_BYTES, true)
4997    }
4998}
4999
5000/// Exact mirror of `gate4agent_types::OperatorGateState`: this crate has no
5001/// dependency on `gate4agent-types` (see the doc comment on
5002/// `HarnessTerminalControlV1`), so the node's gate classification is
5003/// duplicated here as its own closed wire shape rather than imported.
5004/// `hatchery-harness-service` is the single place that maps one into the
5005/// other (`map_operator_gate`), so a field added to one side without the
5006/// other fails to compile there.
5007#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5008#[serde(deny_unknown_fields)]
5009pub struct OperatorGateStateV1 {
5010    pub kind: OperatorGateKindV1,
5011    pub subject: OperatorGateSubjectV1,
5012    pub input: OperatorGateInputV1,
5013    pub options: Vec<OperatorGateOptionV1>,
5014}
5015
5016impl OperatorGateStateV1 {
5017    fn is_valid(&self) -> bool {
5018        let subject_valid = match &self.subject {
5019            OperatorGateSubjectV1::Directory { path: Some(path) } => {
5020                valid_native_single_line(path, HARNESS_GATE_PATH_MAX_BYTES, true)
5021            }
5022            OperatorGateSubjectV1::Directory { path: None }
5023            | OperatorGateSubjectV1::Hooks { .. }
5024            | OperatorGateSubjectV1::McpServers
5025            | OperatorGateSubjectV1::Account
5026            | OperatorGateSubjectV1::ApiKey
5027            | OperatorGateSubjectV1::Appearance
5028            | OperatorGateSubjectV1::Unknown => true,
5029        };
5030        subject_valid
5031            && self.options.len() <= HARNESS_GATE_OPTIONS_MAX
5032            && self.options.iter().all(OperatorGateOptionV1::is_valid)
5033    }
5034}
5035
5036/// Exact mirror of `gate4agent_types::PtyScreenState`: this crate has no
5037/// dependency on `gate4agent-types` (see the doc comment on
5038/// `HarnessTerminalControlV1`), so the node's screen-content classification
5039/// is duplicated here as its own closed wire enum rather than imported.
5040/// `hatchery-harness-service` is the single place that maps one into the
5041/// other, so a variant added to one side without the other fails to compile
5042/// there.
5043#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
5044#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5045pub enum PtyScreenStateV1 {
5046    #[default]
5047    Unknown,
5048    NotAgent { observed_process: String },
5049    OperatorGate { gate: OperatorGateStateV1 },
5050    Failing { reason: String },
5051    Ready,
5052}
5053
5054impl PtyScreenStateV1 {
5055    /// Refuses only the three states that carry a finding -- mirrors
5056    /// `gate4agent_types::PtyScreenState::admits_blind_write` exactly, so a
5057    /// wire consumer gets the same answer as the node and no call site
5058    /// open-codes its own reading. See that method for why `Unknown` admits.
5059    pub fn admits_blind_write(&self) -> bool {
5060        !matches!(
5061            self,
5062            Self::OperatorGate { .. } | Self::NotAgent { .. } | Self::Failing { .. }
5063        )
5064    }
5065
5066    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5067        let ok = match self {
5068            Self::Unknown | Self::Ready => true,
5069            Self::NotAgent { observed_process } => {
5070                valid_native_single_line(observed_process, HARNESS_SCREEN_STATE_PROCESS_MAX_BYTES, true)
5071            }
5072            Self::OperatorGate { gate } => gate.is_valid(),
5073            Self::Failing { reason } => {
5074                valid_native_single_line(reason, HARNESS_SCREEN_STATE_GATE_MAX_BYTES, true)
5075            }
5076        };
5077        if ok { Ok(()) } else { Err(HarnessOperatorApiError::InvalidRuntimeInventory) }
5078    }
5079}
5080
5081#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5082#[serde(deny_unknown_fields)]
5083pub struct HarnessRuntimeSessionV1 {
5084    pub instance_id: u64,
5085    pub generation: u64,
5086    pub provider: String,
5087    pub transport: HarnessRuntimeTransportV1,
5088    pub status: HarnessRuntimeSessionStatusV1,
5089    pub process_id: Option<u32>,
5090    pub terminal_size: Option<HarnessRuntimeTerminalSizeV1>,
5091    pub operation_pending: bool,
5092    pub input_pending: bool,
5093    /// The node's current screen classification. Always populated by every
5094    /// server on this wire -- `Unknown` is a real classification, not an
5095    /// absence marker -- so `Some` is the only value ever actually sent;
5096    /// `Option`-wrapped only so a decoder never has to special-case a
5097    /// missing key.
5098    #[serde(default, skip_serializing_if = "Option::is_none")]
5099    pub screen_state: Option<PtyScreenStateV1>,
5100}
5101
5102impl HarnessRuntimeSessionV1 {
5103    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5104        if self.instance_id == 0
5105            || self.generation == 0
5106            || !valid_runtime_id(&self.provider, 128)
5107            || self.terminal_size.is_some_and(|size| size.rows == 0 || size.columns == 0)
5108        {
5109            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
5110        }
5111        if let Some(screen_state) = &self.screen_state {
5112            screen_state.validate()?;
5113        }
5114        Ok(())
5115    }
5116}
5117
5118#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
5119#[serde(rename_all = "kebab-case")]
5120pub enum HarnessRuntimeMouseProtocolEncodingV1 { Default, Utf8, Sgr }
5121
5122#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5123#[serde(deny_unknown_fields)]
5124pub struct HarnessRuntimeTerminalFrameV1 {
5125    pub sequence: u64,
5126    pub size: HarnessRuntimeTerminalSizeV1,
5127    pub cursor_row: u16,
5128    pub cursor_column: u16,
5129    pub formatted: Vec<u8>,
5130    pub scrollback_formatted: Vec<Vec<u8>>,
5131    pub alternate_screen: bool,
5132    pub mouse_protocol_enabled: bool,
5133    pub mouse_protocol_encoding: HarnessRuntimeMouseProtocolEncodingV1,
5134    /// Unix-epoch milliseconds when the source `TerminalFrame` was
5135    /// materialized (`gate4agent_types::TerminalFrame::produced_at_unix_ms`,
5136    /// stamped once at the PTY snapshot and carried unchanged through every
5137    /// relay hop, including the harness's own terminal ring buffer). An
5138    /// operator computing an age from this must account for clock skew
5139    /// between the node host and its own, not just transit/queue delay.
5140    /// `#[serde(default)]` so an operator client built before this field
5141    /// existed still decodes the frame -- it just can't answer "how stale".
5142    #[serde(default)]
5143    pub produced_at_unix_ms: u64,
5144    /// The screen classification stamped at the instant this frame's screen
5145    /// was materialized. Always populated -- `Unknown` is a real
5146    /// classification, not an absence marker -- so `Some` is the only value
5147    /// ever actually sent; `Option`-wrapped only so a decoder never has to
5148    /// special-case a missing key.
5149    #[serde(default, skip_serializing_if = "Option::is_none")]
5150    pub screen_state: Option<PtyScreenStateV1>,
5151    /// Mirrors `gate4agent_types::TerminalFrame::bracketed_paste` onto the
5152    /// operator wire unconditionally. `None` here means only "the node
5153    /// hasn't captured a value yet" -- the source field's own genuine
5154    /// absence -- never a wire-version gate.
5155    #[serde(default, skip_serializing_if = "Option::is_none")]
5156    pub bracketed_paste: Option<bool>,
5157}
5158// NOTE: gate4agent_types::TerminalFrame::contents (plain-text render) is
5159// deliberately dropped on the wire -- hatchery-tui's apply_terminal_frame
5160// never reads it. No dead field on the wire.
5161
5162impl HarnessRuntimeTerminalFrameV1 {
5163    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5164        if self.size.rows == 0 || self.size.columns == 0 {
5165            return Err(HarnessOperatorApiError::InvalidTerminalPage);
5166        }
5167        if self.scrollback_formatted.len() > HARNESS_TERMINAL_SCROLLBACK_LINES_MAX {
5168            return Err(HarnessOperatorApiError::InvalidTerminalPage);
5169        }
5170        let scrollback_bytes = self.scrollback_formatted.iter()
5171            .map(Vec::len)
5172            .fold(0usize, usize::saturating_add);
5173        if self.formatted.len().saturating_add(scrollback_bytes) > HARNESS_TERMINAL_FRAME_MAX_BYTES {
5174            return Err(HarnessOperatorApiError::InvalidTerminalPage);
5175        }
5176        if let Some(screen_state) = &self.screen_state {
5177            screen_state.validate()?;
5178        }
5179        Ok(())
5180    }
5181}
5182
5183#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
5184#[serde(deny_unknown_fields)]
5185pub struct HarnessRuntimeSessionAddressV1 {
5186    pub node_id: String,
5187    pub incarnation_id: String,
5188    pub workspace_id: String,
5189    pub instance_id: u64,
5190    pub generation: u64,
5191}
5192
5193impl HarnessRuntimeSessionAddressV1 {
5194    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5195        if !valid_runtime_id(&self.node_id, 128)
5196            || self.incarnation_id.len() != 32
5197            || !self.incarnation_id.bytes().all(is_lower_hex)
5198            || !valid_runtime_id(&self.workspace_id, 128)
5199            || self.instance_id == 0
5200            || self.generation == 0
5201        {
5202            return Err(HarnessOperatorApiError::InvalidTerminalPage);
5203        }
5204        Ok(())
5205    }
5206}
5207
5208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5209#[serde(deny_unknown_fields)]
5210pub struct HarnessRuntimeTerminalPageV1 {
5211    pub session: HarnessRuntimeSessionAddressV1,
5212    pub frames: Vec<HarnessRuntimeTerminalFrameV1>,
5213    pub dropped: u64,
5214    pub transport_incomplete: bool,
5215    pub next_cursor: Option<u64>,
5216}
5217
5218impl HarnessRuntimeTerminalPageV1 {
5219    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5220        self.session.validate()?;
5221        if self.frames.len() > usize::from(HARNESS_TERMINAL_PAGE_LIMIT_MAX)
5222            || self.frames.windows(2).any(|pair| pair[0].sequence >= pair[1].sequence)
5223            || self.next_cursor == Some(0)
5224        {
5225            return Err(HarnessOperatorApiError::InvalidTerminalPage);
5226        }
5227        if self.next_cursor.is_some()
5228            && self.next_cursor != self.frames.last().map(|frame| frame.sequence)
5229        {
5230            return Err(HarnessOperatorApiError::InvalidTerminalPage);
5231        }
5232        for frame in &self.frames { frame.validate()?; }
5233        Ok(())
5234    }
5235
5236    pub fn validate_for(
5237        &self,
5238        session: &HarnessRuntimeSessionAddressV1,
5239    ) -> Result<(), HarnessOperatorApiError> {
5240        self.validate()?;
5241        if &self.session != session {
5242            return Err(HarnessOperatorApiError::InvalidTerminalPage);
5243        }
5244        Ok(())
5245    }
5246}
5247
5248#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
5249#[serde(rename_all = "kebab-case")]
5250pub enum HarnessRuntimeManagedModeV1 { Pty, Inline, Acp }
5251
5252#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
5253#[serde(rename_all = "kebab-case")]
5254pub enum HarnessRuntimeManagedStateV1 { IdentityPending, Live, Dormant, Unavailable }
5255
5256#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5257#[serde(deny_unknown_fields)]
5258pub struct HarnessRuntimeSessionBindingV1 {
5259    pub workspace_id: String,
5260    pub instance_id: u64,
5261    pub generation: u64,
5262}
5263
5264#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5265#[serde(deny_unknown_fields)]
5266pub struct HarnessRuntimeManagedSessionV1 {
5267    pub record_id: String,
5268    pub display_name: String,
5269    pub display_name_truncated: bool,
5270    pub provider: String,
5271    pub mode: HarnessRuntimeManagedModeV1,
5272    pub state: HarnessRuntimeManagedStateV1,
5273    pub workspace_id: String,
5274    pub active_binding: Option<HarnessRuntimeSessionBindingV1>,
5275    pub provider_identity_present: bool,
5276    pub updated_at_unix_ms: u64,
5277    /// Count of `ObservationKindV1::ActionBlocked` observations recorded
5278    /// against this managed session, from the harness's own per-session
5279    /// observation projection -- see
5280    /// `hatchery_observation_engine::SessionProjection::blocked_count`.
5281    /// The fleet-wide instrument this exists for: one `runtime-inventory`
5282    /// read answers "how many sessions are stuck behind a gate right now"
5283    /// across every node at once.
5284    pub blocked_count: u64,
5285    /// `received_at_ms` of the most recent such observation, or `None` when
5286    /// `blocked_count` is zero.
5287    pub last_blocked_at_ms: Option<u64>,
5288}
5289
5290impl HarnessRuntimeManagedSessionV1 {
5291    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5292        if !valid_runtime_id(&self.record_id, 128)
5293            || !valid_runtime_id(&self.provider, 128)
5294            || !valid_runtime_id(&self.workspace_id, 128)
5295            || self.display_name.is_empty()
5296            || self.display_name.len() > 256
5297            || self.display_name.chars().any(char::is_control)
5298            || self.updated_at_unix_ms == 0
5299            || self.active_binding.as_ref().is_some_and(|binding| {
5300                !valid_runtime_id(&binding.workspace_id, 128)
5301                    || binding.instance_id == 0
5302                    || binding.generation == 0
5303            })
5304            || (self.blocked_count == 0) != self.last_blocked_at_ms.is_none()
5305        {
5306            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
5307        }
5308        Ok(())
5309    }
5310}
5311
5312#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5313#[serde(deny_unknown_fields)]
5314pub struct HarnessRuntimeInventoryPageV1 {
5315    pub nodes: Vec<HarnessRuntimeNodeInventoryV1>,
5316    pub next_cursor: Option<String>,
5317}
5318
5319impl HarnessRuntimeInventoryPageV1 {
5320    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5321        if self.nodes.len() > usize::from(HARNESS_RUNTIME_INVENTORY_PAGE_LIMIT_MAX)
5322            || self.nodes.windows(2).any(|pair| pair[0].node_id >= pair[1].node_id)
5323            || self.next_cursor.as_ref().is_some_and(|cursor| match self.nodes.last() {
5324                Some(last) => cursor != &last.node_id,
5325                None => true,
5326            })
5327        {
5328            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
5329        }
5330        for node in &self.nodes { node.validate()?; }
5331        Ok(())
5332    }
5333}
5334
5335#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5336#[serde(rename_all = "kebab-case")]
5337pub enum HarnessNativeSessionCatalogScopeV1 { Workspace, Unregistered }
5338
5339#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
5340#[serde(rename_all = "kebab-case")]
5341pub enum HarnessNativeSessionCatalogWindowV1 { Recent, Older }
5342
5343#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5344#[serde(deny_unknown_fields)]
5345pub struct HarnessNativeSessionRouteV1 {
5346    pub node_id: String,
5347    pub incarnation_id: String,
5348    pub scope: HarnessNativeSessionCatalogScopeV1,
5349    pub workspace_id: Option<String>,
5350    pub provider: String,
5351}
5352
5353impl HarnessNativeSessionRouteV1 {
5354    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5355        let scope_valid = matches!(
5356            (self.scope, self.workspace_id.as_ref()),
5357            (HarnessNativeSessionCatalogScopeV1::Workspace, Some(_))
5358                | (HarnessNativeSessionCatalogScopeV1::Unregistered, None)
5359        );
5360        if !scope_valid
5361            || !valid_runtime_id(&self.node_id, 128)
5362            || self.incarnation_id.len() != 32
5363            || !self.incarnation_id.bytes().all(is_lower_hex)
5364            || !valid_runtime_id(&self.provider, 128)
5365            || self.workspace_id.as_deref().is_some_and(|value| {
5366                !valid_runtime_id(value, 128)
5367            })
5368        {
5369            return Err(HarnessOperatorApiError::InvalidNativeHistory);
5370        }
5371        Ok(())
5372    }
5373}
5374
5375#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5376#[serde(deny_unknown_fields)]
5377pub struct HarnessNativeSessionSelectionV1 {
5378    pub route: HarnessNativeSessionRouteV1,
5379    pub catalog_revision: u64,
5380    pub recent_cutoff_unix_ms: u64,
5381    pub selection_id: String,
5382}
5383
5384impl HarnessNativeSessionSelectionV1 {
5385    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5386        self.route.validate()?;
5387        if self.catalog_revision == 0 || !valid_native_selection_id(&self.selection_id) {
5388            return Err(HarnessOperatorApiError::InvalidNativeHistory);
5389        }
5390        Ok(())
5391    }
5392}
5393
5394#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
5395#[serde(rename_all = "kebab-case")]
5396pub enum HarnessNativeSessionExternalGroupKindV1 { Project, Global }
5397
5398#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5399#[serde(deny_unknown_fields)]
5400pub struct HarnessNativeSessionExternalGroupV1 {
5401    pub group_id: String,
5402    pub kind: HarnessNativeSessionExternalGroupKindV1,
5403    pub display_name: String,
5404}
5405
5406impl HarnessNativeSessionExternalGroupV1 {
5407    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5408        if !valid_native_group_id(&self.group_id)
5409            || !valid_native_single_line(&self.display_name, 256, true)
5410            || matches!(self.display_name.as_str(), "." | "..")
5411            || self.display_name.contains('/')
5412            || self.display_name.contains('\\')
5413            || self.display_name.contains(':')
5414        {
5415            return Err(HarnessOperatorApiError::InvalidNativeHistory);
5416        }
5417        Ok(())
5418    }
5419}
5420
5421#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5422#[serde(deny_unknown_fields)]
5423pub struct HarnessNativeSessionCatalogEntryV1 {
5424    pub selection_id: String,
5425    pub title: Option<String>,
5426    pub modified_at_unix_ms: Option<u64>,
5427    pub model: Option<String>,
5428    pub message_count: u64,
5429    pub completed_turn_count: Option<u64>,
5430    pub external_group: Option<HarnessNativeSessionExternalGroupV1>,
5431    pub record_id: Option<String>,
5432}
5433
5434impl HarnessNativeSessionCatalogEntryV1 {
5435    fn validate_for_route(
5436        &self,
5437        route: &HarnessNativeSessionRouteV1,
5438    ) -> Result<(), HarnessOperatorApiError> {
5439        if !valid_native_selection_id(&self.selection_id)
5440            || self.title.as_ref().is_some_and(|value| {
5441                !valid_native_single_line(value, 512, false)
5442            })
5443            || self.model.as_ref().is_some_and(|value| {
5444                !valid_native_single_line(value, 512, true)
5445            })
5446            || self.record_id.as_deref().is_some_and(|value| {
5447                !valid_runtime_id(value, 128)
5448            })
5449        {
5450            return Err(HarnessOperatorApiError::InvalidNativeHistory);
5451        }
5452        if let Some(group) = &self.external_group { group.validate()?; }
5453        let route_fields_valid = match route.scope {
5454            HarnessNativeSessionCatalogScopeV1::Workspace => self.external_group.is_none(),
5455            HarnessNativeSessionCatalogScopeV1::Unregistered => {
5456                self.external_group.is_some() && self.record_id.is_none()
5457            }
5458        };
5459        if !route_fields_valid {
5460            return Err(HarnessOperatorApiError::InvalidNativeHistory);
5461        }
5462        Ok(())
5463    }
5464}
5465
5466#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5467#[serde(deny_unknown_fields)]
5468pub struct HarnessNativeSessionCatalogSummaryV1 {
5469    pub catalog_revision: u64,
5470    pub recent_cutoff_unix_ms: u64,
5471    pub recent_total_count: u32,
5472    pub older_total_count: u32,
5473    pub recent_next_after_selection_id: Option<String>,
5474    pub recent_has_more: bool,
5475}
5476
5477impl HarnessNativeSessionCatalogSummaryV1 {
5478    fn validate(&self, entry_count: usize) -> Result<(), HarnessOperatorApiError> {
5479        if self.catalog_revision == 0
5480            || self.recent_next_after_selection_id.as_deref().is_some_and(|value| {
5481                !valid_native_selection_id(value)
5482            })
5483            || self.recent_has_more != self.recent_next_after_selection_id.is_some()
5484            || usize::try_from(self.recent_total_count).map_or(true, |count| count < entry_count)
5485            || self.recent_has_more != (usize::try_from(self.recent_total_count)
5486                .unwrap_or(usize::MAX) > entry_count)
5487        {
5488            return Err(HarnessOperatorApiError::InvalidNativeHistory);
5489        }
5490        Ok(())
5491    }
5492}
5493
5494#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5495#[serde(deny_unknown_fields)]
5496pub struct HarnessNativeSessionsCatalogedV1 {
5497    pub route: HarnessNativeSessionRouteV1,
5498    pub entries: Vec<HarnessNativeSessionCatalogEntryV1>,
5499    pub summary: Option<HarnessNativeSessionCatalogSummaryV1>,
5500}
5501
5502impl HarnessNativeSessionsCatalogedV1 {
5503    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5504        self.route.validate()?;
5505        validate_native_entries(&self.route, &self.entries)?;
5506        if let Some(summary) = &self.summary { summary.validate(self.entries.len())?; }
5507        Ok(())
5508    }
5509}
5510
5511#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5512#[serde(deny_unknown_fields)]
5513pub struct HarnessNativeSessionCatalogPageV1 {
5514    pub window: HarnessNativeSessionCatalogWindowV1,
5515    pub revision: u64,
5516    pub entries: Vec<HarnessNativeSessionCatalogEntryV1>,
5517    pub next_after_selection_id: Option<String>,
5518    pub remaining_count: u32,
5519    pub has_more: bool,
5520}
5521
5522impl HarnessNativeSessionCatalogPageV1 {
5523    fn validate_for_route(
5524        &self,
5525        route: &HarnessNativeSessionRouteV1,
5526    ) -> Result<(), HarnessOperatorApiError> {
5527        validate_native_entries(route, &self.entries)?;
5528        if self.revision == 0
5529            || self.next_after_selection_id.as_deref().is_some_and(|value| {
5530                !valid_native_selection_id(value)
5531            })
5532            || self.has_more != self.next_after_selection_id.is_some()
5533            || self.has_more != (self.remaining_count > 0)
5534        {
5535            return Err(HarnessOperatorApiError::InvalidNativeHistory);
5536        }
5537        Ok(())
5538    }
5539}
5540
5541#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5542#[serde(deny_unknown_fields)]
5543pub struct HarnessNativeSessionsPagedV1 {
5544    pub route: HarnessNativeSessionRouteV1,
5545    pub page: HarnessNativeSessionCatalogPageV1,
5546}
5547
5548impl HarnessNativeSessionsPagedV1 {
5549    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5550        self.route.validate()?;
5551        self.page.validate_for_route(&self.route)
5552    }
5553}
5554
5555#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
5556#[serde(rename_all = "kebab-case")]
5557pub enum HarnessNativeSessionPreviewRoleV1 { User, Assistant }
5558
5559#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5560#[serde(deny_unknown_fields)]
5561pub struct HarnessNativeSessionPreviewMessageV1 {
5562    pub role: HarnessNativeSessionPreviewRoleV1,
5563    pub text: String,
5564}
5565
5566#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5567#[serde(deny_unknown_fields)]
5568pub struct HarnessNativeSessionPreviewV1 {
5569    pub title: Option<String>,
5570    pub modified_at_unix_ms: Option<u64>,
5571    pub model: Option<String>,
5572    pub message_count: u64,
5573    pub message_count_exact: bool,
5574    pub completed_turn_count: Option<u64>,
5575    pub total_tokens: Option<u64>,
5576    pub truncated: bool,
5577    pub messages: Vec<HarnessNativeSessionPreviewMessageV1>,
5578}
5579
5580impl HarnessNativeSessionPreviewV1 {
5581    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5582        if self.title.as_ref().is_some_and(|value| {
5583            !valid_native_single_line(value, 512, false)
5584        })
5585            || self.model.as_ref().is_some_and(|value| {
5586                !valid_native_single_line(value, 512, true)
5587            })
5588            || self.messages.len()
5589                > usize::from(HARNESS_NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
5590            || self.messages.iter().any(|message| {
5591                message.text.len() > HARNESS_NATIVE_SESSION_PREVIEW_TEXT_MAX_BYTES
5592                    || message.text.chars().any(|character| {
5593                        character.is_control()
5594                            && !matches!(character, '\n' | '\r' | '\t')
5595                    })
5596            })
5597        {
5598            return Err(HarnessOperatorApiError::InvalidNativeHistory);
5599        }
5600        Ok(())
5601    }
5602}
5603
5604#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5605#[serde(deny_unknown_fields)]
5606pub struct HarnessNativeSessionPreviewedV1 {
5607    pub selection: HarnessNativeSessionSelectionV1,
5608    pub preview: HarnessNativeSessionPreviewV1,
5609}
5610
5611impl HarnessNativeSessionPreviewedV1 {
5612    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5613        self.selection.validate()?;
5614        self.preview.validate()
5615    }
5616}
5617
5618/// `PreviewSessionRecord`'s reply: reuses `HarnessNativeSessionPreviewV1`
5619/// verbatim rather than defining a session-record-specific preview shape --
5620/// the node's own `SessionRecordPreview` and `NativeSessionPreview` are the
5621/// same type (`pub type NativeSessionPreview = SessionRecordPreview;` in
5622/// `gate4agent-node-protocol`), so the wire mirrors that identity instead of
5623/// inventing a duplicate.
5624#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5625#[serde(deny_unknown_fields)]
5626pub struct HarnessSessionRecordPreviewedV1 {
5627    pub record_id: String,
5628    pub preview: HarnessNativeSessionPreviewV1,
5629}
5630
5631impl HarnessSessionRecordPreviewedV1 {
5632    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5633        if !valid_runtime_id(&self.record_id, 128) {
5634            return Err(HarnessOperatorApiError::InvalidRuntimeInventory);
5635        }
5636        self.preview.validate()
5637    }
5638}
5639
5640/// `ResumeSessionRecord`'s reply: unlike `ResumeSession` (ack-only, no
5641/// address -- see that request variant's doc comment), the node returns the
5642/// freshly spawned session's address synchronously alongside the updated
5643/// record, and this wire mirrors that exactly.
5644#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5645#[serde(deny_unknown_fields)]
5646pub struct HarnessSessionRecordResumedV1 {
5647    pub record: HarnessRuntimeManagedSessionV1,
5648    pub session: HarnessRuntimeSessionAddressV1,
5649}
5650
5651impl HarnessSessionRecordResumedV1 {
5652    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5653        self.record.validate()?;
5654        self.session.validate()
5655    }
5656}
5657
5658/// `IndexNativeSession`'s reply: echoes the committed selection alongside
5659/// the resulting managed-session record, the same pairing
5660/// `HarnessNativeSessionPreviewedV1` uses for its own selection/payload
5661/// echo.
5662#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5663#[serde(deny_unknown_fields)]
5664pub struct HarnessNativeSessionIndexedV1 {
5665    pub selection: HarnessNativeSessionSelectionV1,
5666    pub record: HarnessRuntimeManagedSessionV1,
5667}
5668
5669impl HarnessNativeSessionIndexedV1 {
5670    fn validate(&self) -> Result<(), HarnessOperatorApiError> {
5671        self.selection.validate()?;
5672        self.record.validate()
5673    }
5674}
5675
5676#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
5677#[serde(rename_all = "kebab-case")]
5678pub enum HarnessOperatorMutationOutcomeV1 {
5679    Applied,
5680    Replayed,
5681}
5682
5683#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5684#[serde(rename_all = "kebab-case")]
5685pub enum HarnessOperatorHostErrorV1 {
5686    InvalidRequest,
5687    Unauthorized,
5688    NotFound,
5689    Conflict,
5690    TooLarge,
5691    Deadline,
5692    Busy,
5693    Unavailable,
5694    // Distinct from `Deadline`: a read timing out has no side effect, so
5695    // "safe to retry" is implicit. A `SpawnSession` whose C2/Node round trip
5696    // is lost after being accepted carries no such guarantee -- the session
5697    // may or may not exist. This case exists to tell the operator "check the
5698    // runtime inventory before retrying", not "retry freely".
5699    OutcomeUnknown,
5700    // Distinct from the generic `Unavailable`/`Internal` buckets a bare
5701    // `NodeFailureCode::BackendOperationFailed` would otherwise collapse
5702    // into: the node rejected this `SpawnSession` specifically because the
5703    // requested provider profile does not declare the requested transport.
5704    // Names its own inputs -- mirrors the kernel's own
5705    // `KernelCommandError::UnsupportedTransport { agent_id, transport }`,
5706    // which this variant exists to carry through to the operator instead of
5707    // collapsing into a bare `Unavailable`/`Deadline` that names neither
5708    // (see the `spawn_session_with_deadline` commit-wait loop in
5709    // `hatchery-node`'s `server.rs`, which used to lose exactly this
5710    // reason to a blind commit-deadline poll). This is why the enum gave up
5711    // `Copy` -- every other variant is a bare tag, but the whole point here
5712    // is not making the operator already know what it asked for; it is
5713    // proving the host actually looked at the same request the operator
5714    // sent.
5715    UnsupportedTransport {
5716        agent: String,
5717        transport: HarnessRuntimeTransportV1,
5718    },
5719    // Distinct from the generic `Unavailable` bucket a bare `NodeFailureCode
5720    // ::UnsupportedCapability` used to collapse into: the node did not say
5721    // "busy" or "disconnected", it said this exact capability does not
5722    // exist for the addressed session/provider. `Unavailable` reads as
5723    // transient to a caller ("try again shortly"); this operation can never
5724    // succeed, so folding it into `Unavailable` invited a retry that was
5725    // guaranteed to fail the same way forever. Every mapper on this wire
5726    // that used to fold `NodeFailureCode::UnsupportedCapability` into
5727    // `Unavailable` now returns this instead.
5728    UnsupportedCapability,
5729    Internal,
5730    // Added alongside `hatchery-harness-light` (the P2.2 light-harness
5731    // extraction), riding the V11 era: distinct from `NotFound` (a request
5732    // scoped to an id that provably does not and never will exist under the
5733    // current backend, e.g. any task/run id against a backend with no task
5734    // kernel) and from `Internal` (an unexpected failure). `Unsupported`
5735    // means "this verb is recognized and well-formed, but this particular
5736    // operator host does not implement it" -- a light-harness host rejecting
5737    // the task-kernel mutation family (`SubmitIntent` and its authorized
5738    // siblings), the node-workspace/native-history/session-record/resource-
5739    // mutation families, `TerminalRead`, and `SubscribeEvents` in its first
5740    // slice, per the app-harness protocol contract's fail-closed principle
5741    // (a verb a host cannot serve gets a typed rejection, never silent
5742    // misbehavior or a misleading `NotFound`). Purely additive: an existing
5743    // host (the full harness) never returns it, and every wire version this
5744    // enum has ever shipped under already tolerates an unrecognized error
5745    // variant the same way `#[serde(rename_all = "kebab-case")]` does for
5746    // any other closed enum here -- see this module's round-trip tests. No
5747    // skew risk in practice: `hatchery-harness-light` pairs this crate
5748    // in-process (same build, same binary), never across a version boundary.
5749    Unsupported,
5750    // The host decoded the envelope but its declared `build_stamp` did not
5751    // match this side's own `BUILD_STAMP` (see `HarnessOperatorApiError::
5752    // BuildStampMismatch`, which this carries verbatim onto the wire so the
5753    // caller sees both stamps instead of the generic `InvalidRequest` every
5754    // other malformed-envelope shape collapses to). Reaching this variant
5755    // itself proves a build skew between the two loopback sides -- this
5756    // protocol has exactly one accepted build stamp, so a well-formed peer
5757    // never triggers it against a matching one.
5758    BuildStampMismatch { expected: String, received: String },
5759    // -- Everything below names its own inputs instead of collapsing into a
5760    // bare `Conflict`. `map_operator_service_error` (`gate4agent-harness-
5761    // service`'s `runtime.rs`) used to have a catch-all `_ => Conflict` arm
5762    // covering every one of these -- the reason a stack whose derived
5763    // launch-plan catalogue exceeded its page size had every `spec save`
5764    // refused with nothing but the word "Conflict": `InvalidTaskLaunchSelection`
5765    // was one of the swallowed variants. Every `HarnessServiceError` variant
5766    // that arm used to swallow now has its own arm there and its own named
5767    // variant here; the catch-all is gone. Fields whose native type lives in
5768    // a crate this one does not depend on (`gate4agent-node-protocol`,
5769    // `hatchery-harness-service`, or the foreign `HarnessEngineError` this
5770    // crate must not open) ride the wire as `String` -- the same treatment
5771    // `UnsupportedTransport::agent` above already gives an unreachable id.
5772    //
5773    // The one exception is `HarnessServiceError::Engine`, whose OWN inner
5774    // variants live in `hatchery-harness-engine` -- a crate under active
5775    // edit by another worker this fix must not open. Every non-`NotFound`
5776    // `HarnessEngineError` still collapses into `EngineRefused { detail }`
5777    // (its Debug rendering), which is strictly more than the bare `Conflict`
5778    // it used to get, without requiring that file's variant list.
5779    EngineRefused { detail: String },
5780    /// The one variant named explicitly in this fix's own brief: a reviewed
5781    /// task-launch selection failed one of `validate_current_launch_options`/
5782    /// `validate_current_issued_launch_options`'s checks. `why` is always one
5783    /// of a small set of fixed literals (see `HarnessServiceError::
5784    /// InvalidTaskLaunchSelection`'s doc comment); `plan_id` is `Some`
5785    /// whenever the failing check concerns a specific plan.
5786    InvalidLaunchSelection {
5787        task_id: HarnessTaskId,
5788        plan_id: Option<HarnessSelectorV1>,
5789        why: String,
5790    },
5791    UnsupportedCheckpointVersion { version: u16 },
5792    InvalidDispatchContext { reason: String },
5793    MutationDigestMismatch,
5794    DispatchFingerprintUnavailable,
5795    NonAtomicRunOperation,
5796    AcceptedSpawnProofRequired,
5797    InvalidAcceptedSpawnProof { reason: String },
5798    DeliveryAuthorityWindowClosed,
5799    DeliveryCompilationInvalid,
5800    InvalidStagedDeliveryProof { reason: String },
5801    AtomicDeliveryCommitRequired,
5802    ContinuationAuthorityWindowClosed,
5803    InvalidContinuationProof { reason: String },
5804    AtomicContinuationBindRequired,
5805    InvalidHarnessMcpReservation { reason: String },
5806    HarnessMcpGrantActorRefused {
5807        actor_kind: String,
5808        parent_run_id: Option<HarnessRunId>,
5809        grant_actor_run_id: HarnessRunId,
5810    },
5811    HarnessMcpGrantOperationLinkRefused {
5812        grant_id: SessionGrantId,
5813        operation_id: HarnessOperationId,
5814        existing_grant_id: SessionGrantId,
5815    },
5816    HarnessMcpGrantRevisionRefused {
5817        grant_id: SessionGrantId,
5818        durable_revision: HarnessRevision,
5819        presented_revision: HarnessRevision,
5820    },
5821    HarnessMcpGrantLinkRefused {
5822        grant_id: SessionGrantId,
5823        grant_actor_run_id: HarnessRunId,
5824        dispatch_actor_run_id: HarnessRunId,
5825    },
5826    HarnessMcpGrantTargetRefused {
5827        grant_id: SessionGrantId,
5828        presented_target: HarnessGrantTargetV1,
5829        allowed_targets: Vec<HarnessGrantTargetV1>,
5830    },
5831    HarnessMcpReplayMismatch,
5832    HarnessMcpProofMismatch,
5833    HarnessMcpArmProofReservationFieldRefused {
5834        field: String,
5835        durable: String,
5836        proof: String,
5837    },
5838    HarnessMcpArmProofRouteRefused {
5839        field: String,
5840        durable: String,
5841        route: String,
5842    },
5843    HarnessMcpArmProofBindingRefused {
5844        field: String,
5845        expected: String,
5846        actual: String,
5847    },
5848    HarnessMcpArmDurableLookupMissing {
5849        missing: String,
5850        operation_id: Option<HarnessOperationId>,
5851        reservation_id: Option<String>,
5852    },
5853    HarnessMcpArmReservationNotReadyRefused {
5854        reservation_id: String,
5855        state: String,
5856        armed_at_unix_ms: u64,
5857        updated_at_unix_ms: u64,
5858        expires_at_unix_ms: u64,
5859    },
5860    HarnessMcpArmRouteInvalid {
5861        operation_id: HarnessOperationId,
5862        field: String,
5863        value: String,
5864    },
5865    HarnessMcpLaunchPolicyRefused {
5866        plan_policy: String,
5867        reservation_present: bool,
5868    },
5869    HarnessMcpLaunchReservationNotArmedRefused {
5870        reservation_id: String,
5871        state: String,
5872    },
5873    HarnessMcpLaunchOperationRefused {
5874        reservation_operation_id: HarnessOperationId,
5875        dispatch_operation_id: HarnessOperationId,
5876    },
5877    HarnessMcpLaunchGrantRefused {
5878        plan_grant_id: SessionGrantId,
5879        plan_grant_revision: HarnessRevision,
5880        reservation_grant_id: SessionGrantId,
5881        reservation_grant_revision: HarnessRevision,
5882    },
5883    HarnessMcpSpecializedTransitionRequired,
5884    OperatorRequestConflict { operation_id: HarnessOperationId },
5885    InvalidOperatorTaskTransition {
5886        from: HarnessTaskStateV1,
5887        to: HarnessTaskStateV1,
5888    },
5889    TaskHasActiveRun,
5890    ExecutionSpecRevisionMismatch {
5891        expected: Option<HarnessRevision>,
5892        actual: Option<HarnessRevision>,
5893    },
5894    ExecutionSpecLaunchMismatch,
5895    IssuedExecutionCasMismatch {
5896        expected: HarnessExpectedExecutionSpecRevisionV1,
5897        spec: Option<HarnessRevision>,
5898        issuance: Option<HarnessRevision>,
5899    },
5900    TaskNotReady,
5901    // `TaskNotReady` answered three different questions with one word: the
5902    // task is not in `Ready`, a dependency of it is unfinished, or it already
5903    // has a live run. Only the first is something the operator can see for
5904    // itself with `task get`; the other two are facts the host holds and the
5905    // operator does not. These two carry them, for the same reason
5906    // `UnsupportedTransport` above carries its own inputs -- the point is not
5907    // making the operator already know what blocked it, it is proving the
5908    // host looked. `dependency_ids` lists exactly the dependencies that are
5909    // not `Done` yet, so an operator sees what to finish first instead of a
5910    // shrug.
5911    TaskDependenciesNotDone {
5912        task_id: String,
5913        dependency_ids: Vec<String>,
5914    },
5915    // Unreachable through today's paths -- every route back into `Ready`
5916    // (`operator_move_task`, `operator_retry_task`) already refuses while a
5917    // non-terminal run exists, and `start_task_v2` flips `Ready`->`Running`
5918    // atomically with run creation. Named anyway rather than folded back into
5919    // `TaskNotReady`, so that if a future path into `Ready` ever reopens it,
5920    // the refusal arrives already saying which of the three things happened.
5921    TaskStartBlockedByRun { task_id: String },
5922    SchedulerResourceExhausted,
5923    SchedulerInvalidGraph { reason: String },
5924}
5925
5926#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5927#[serde(deny_unknown_fields)]
5928pub struct HarnessReadEnvelopeV1 {
5929    pub build_stamp: String,
5930    pub credential: HarnessReadCredential,
5931    pub request: HarnessReadRequestV1,
5932}
5933
5934impl HarnessReadEnvelopeV1 {
5935    /// Builds an envelope carrying this binary's own [`BUILD_STAMP`] -- the
5936    /// one place every caller in this tree (this crate's own tests,
5937    /// `hatchery-harness-client`) should build one from, rather than
5938    /// hand-filling `build_stamp` at each site.
5939    pub fn new(credential: HarnessReadCredential, request: HarnessReadRequestV1) -> Self {
5940        Self { build_stamp: BUILD_STAMP.to_string(), credential, request }
5941    }
5942
5943    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
5944        if self.build_stamp != BUILD_STAMP {
5945            return Err(HarnessReadApiError::BuildStampMismatch {
5946                expected: BUILD_STAMP.to_string(),
5947                received: self.build_stamp.clone(),
5948            });
5949        }
5950        validate_credential(self.credential.expose())?;
5951        self.request.validate()
5952    }
5953}
5954
5955#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5956#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5957pub enum HarnessReadRequestV1 {
5958    ContextGet,
5959    MonitorGet { run_id: Option<HarnessRunId> },
5960    TimelineRead {
5961        run_id: Option<HarnessRunId>,
5962        after_sequence: Option<u64>,
5963        limit: u16,
5964    },
5965    TasksList {
5966        after_task_id: Option<HarnessTaskId>,
5967        state: Option<HarnessTaskStateV1>,
5968        /// "What did this task spawn": restricts the page to tasks whose
5969        /// own `parent_task_id` equals this one -- direct children only,
5970        /// never deeper descendants. Additive over the original bare
5971        /// `{ after_task_id, state, limit }` shape, so `#[serde(default)]`
5972        /// keeps an older caller decoding. Composes with `state` (both
5973        /// filters apply together) and pages the same way via
5974        /// `after_task_id`.
5975        #[serde(default)]
5976        parent_task_id: Option<HarnessTaskId>,
5977        limit: u16,
5978    },
5979    TaskGet { task_id: HarnessTaskId },
5980    RunsList {
5981        task_id: Option<HarnessTaskId>,
5982        after_run_id: Option<HarnessRunId>,
5983        lifecycle: Option<HarnessRunLifecycleV1>,
5984        /// "What did this run spawn": restricts the page to runs whose own
5985        /// `parent_run_id` equals this one -- direct children only. Additive
5986        /// over the original shape (`#[serde(default)]`), composes with
5987        /// `task_id`/`lifecycle`, pages the same way via `after_run_id`.
5988        #[serde(default)]
5989        parent_run_id: Option<HarnessRunId>,
5990        limit: u16,
5991    },
5992    RunGet { run_id: HarnessRunId },
5993    OperationGet { operation_id: HarnessOperationId },
5994    /// D5, Slice D: an agent creates a task under its own subtree --
5995    /// `parent_task_id: None` means "under my own task"; `Some` must name
5996    /// the caller's own task or a strict descendant of it (checked
5997    /// service-side, `HarnessEngine::task_is_strict_descendant`), never a
5998    /// task system-wide the way the operator's own `HarnessCreateTaskRequestV1`
5999    /// can. Gated by `grant.task_permissions.create`. Every refusal --
6000    /// unreachable parent, a terminal parent, or an invalid title/body/
6001    /// dependencies -- travels back as `HarnessTaskCreateResultV1`'s own
6002    /// named variant, never a host error. This bound is the coarse
6003    /// wire-level ceiling only; the authoritative title/body rule is the
6004    /// engine's own `HarnessTaskV1::validate()`, reached when the mutation
6005    /// applies.
6006    TaskCreate {
6007        title: String,
6008        body: String,
6009        parent_task_id: Option<HarnessTaskId>,
6010    },
6011    /// D5, Slice D: restricted service-side to a STRICT descendant of the
6012    /// caller's own task -- never the task that governs the caller's own
6013    /// run (`HarnessTaskMoveResultV1::TaskIsOwn`). Gated by
6014    /// `grant.task_permissions.mutate`. `to` names the operator's own task
6015    /// graph (`validate_operator_move` in `hatchery-harness-service`); an
6016    /// illegal transition or a stale `expected_revision` is a named refusal,
6017    /// never a host error.
6018    TaskMove {
6019        task_id: HarnessTaskId,
6020        expected_revision: HarnessRevision,
6021        to: HarnessTaskStateV1,
6022    },
6023    /// S10: a session reports its OWN run finished -- resolved from the
6024    /// caller's own grant binding (`actor_run_id`), the same field every
6025    /// other agent verb resolves its identity from; there is no run-id
6026    /// argument, so a session can never finish another session's run.
6027    /// `done` applies `HarnessLifecycleProjectionV1::CompletedReview`
6028    /// (`hatchery-harness-service`), landing the task in `Review` --
6029    /// never `Done`, the review gate a session cannot skip. `failed`
6030    /// applies `HarnessLifecycleProjectionV1::Failed` and records a
6031    /// retryable `HarnessFailureV1`. Never gated by any grant permission
6032    /// (unconditional in `allowed_tool_ids`, the same way `g4a_context_get`
6033    /// is): every session needs the ability to report its own work
6034    /// finished regardless of what else its grant allows, or nothing ever
6035    /// closes -- the exact structural gap this verb exists to close.
6036    /// `summary` is accepted on the wire for backward compatibility but has
6037    /// no effect: it used to be posted as mail to the task's own forum,
6038    /// removed 2026-09-17 when the mailbox moved to its own service (see
6039    /// `hatchery-harness-service::read::execute_exact_binding_read`'s
6040    /// `RunFinish` handler). Not idempotent: a second call against an
6041    /// already-terminal run is refused by name as
6042    /// `HarnessRunFinishResultV1::AlreadyFinished`, never silently accepted.
6043    RunFinish {
6044        outcome: HarnessRunFinishOutcomeV1,
6045        #[serde(default)]
6046        summary: Option<String>,
6047    },
6048}
6049
6050impl HarnessReadRequestV1 {
6051    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6052        match self {
6053            Self::RunFinish { summary, .. } => {
6054                if let Some(summary) = summary {
6055                    if summary.len() > HARNESS_BODY_MAX_BYTES {
6056                        return Err(HarnessReadApiError::InvalidText("run finish summary"));
6057                    }
6058                }
6059                Ok(())
6060            }
6061            Self::TaskCreate { title, body, parent_task_id } => {
6062                if title.is_empty()
6063                    || title.len() > HARNESS_TITLE_MAX_BYTES
6064                    || body.len() > HARNESS_BODY_MAX_BYTES
6065                {
6066                    return Err(HarnessReadApiError::InvalidTaskCreate);
6067                }
6068                if let Some(parent_task_id) = parent_task_id {
6069                    parent_task_id.validate().map_err(HarnessReadApiError::Protocol)?;
6070                }
6071                Ok(())
6072            }
6073            Self::TaskMove { task_id, expected_revision, .. } => {
6074                task_id.validate().map_err(HarnessReadApiError::Protocol)?;
6075                expected_revision.validate().map_err(HarnessReadApiError::Protocol)
6076            }
6077            Self::TimelineRead { run_id, after_sequence, limit } => {
6078                if let Some(run_id) = run_id {
6079                    run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6080                }
6081                if after_sequence == &Some(0) {
6082                    return Err(HarnessReadApiError::InvalidCursor);
6083                }
6084                validate_limit(*limit, HARNESS_TIMELINE_PAGE_LIMIT_MAX)
6085            }
6086            Self::TasksList { after_task_id, parent_task_id, limit, .. } => {
6087                if let Some(task_id) = after_task_id {
6088                    task_id.validate().map_err(HarnessReadApiError::Protocol)?;
6089                }
6090                if let Some(task_id) = parent_task_id {
6091                    task_id.validate().map_err(HarnessReadApiError::Protocol)?;
6092                }
6093                validate_limit(*limit, HARNESS_ENTITY_PAGE_LIMIT_MAX)
6094            }
6095            Self::RunsList { task_id, after_run_id, parent_run_id, limit, .. } => {
6096                if let Some(task_id) = task_id {
6097                    task_id.validate().map_err(HarnessReadApiError::Protocol)?;
6098                }
6099                if let Some(run_id) = after_run_id {
6100                    run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6101                }
6102                if let Some(run_id) = parent_run_id {
6103                    run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6104                }
6105                validate_limit(*limit, HARNESS_ENTITY_PAGE_LIMIT_MAX)
6106            }
6107            Self::TaskGet { task_id } => task_id.validate().map_err(HarnessReadApiError::Protocol),
6108            Self::RunGet { run_id } => run_id.validate().map_err(HarnessReadApiError::Protocol),
6109            Self::OperationGet { operation_id } => {
6110                operation_id.validate().map_err(HarnessReadApiError::Protocol)
6111            }
6112            Self::MonitorGet { run_id } => {
6113                if let Some(run_id) = run_id {
6114                    run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6115                }
6116                Ok(())
6117            }
6118            Self::ContextGet => Ok(()),
6119        }
6120    }
6121}
6122
6123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6124#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
6125pub enum HarnessReadReplyV1 {
6126    Ok { response: HarnessReadResponseV1 },
6127    Error { error: HarnessReadHostErrorV1 },
6128}
6129
6130impl HarnessReadReplyV1 {
6131    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6132        match self {
6133            Self::Ok { response } => response.validate(),
6134            Self::Error { .. } => Ok(()),
6135        }
6136    }
6137}
6138
6139#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6140#[serde(tag = "kind", content = "value", rename_all = "kebab-case", deny_unknown_fields)]
6141pub enum HarnessReadResponseV1 {
6142    Context(SessionContextV1),
6143    Monitor(SessionMonitorV1),
6144    Timeline(TimelinePageV1),
6145    Tasks(TaskPageV1),
6146    Task(RedactedTaskV1),
6147    Runs(RunPageV1),
6148    Run(RedactedRunV1),
6149    Operation(RedactedOperationV1),
6150    TaskCreate(HarnessTaskCreateResultV1),
6151    TaskMove(HarnessTaskMoveResultV1),
6152    RunFinish(HarnessRunFinishResultV1),
6153}
6154
6155impl HarnessReadResponseV1 {
6156    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6157        match self {
6158            Self::Context(value) => value.validate(),
6159            Self::Monitor(value) => value.validate(),
6160            Self::Timeline(value) => value.validate(),
6161            Self::Tasks(value) => value.validate(),
6162            Self::Task(value) => value.validate(),
6163            Self::Runs(value) => value.validate(),
6164            Self::Run(value) => value.validate(),
6165            Self::Operation(value) => value.validate(),
6166            Self::TaskCreate(value) => value.validate().map_err(HarnessReadApiError::Protocol),
6167            Self::TaskMove(value) => value.validate().map_err(HarnessReadApiError::Protocol),
6168            Self::RunFinish(value) => value.validate().map_err(HarnessReadApiError::Protocol),
6169        }
6170    }
6171}
6172
6173#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6174#[serde(rename_all = "kebab-case")]
6175pub enum HarnessReadHostErrorV1 {
6176    InvalidRequest,
6177    Unauthorized,
6178    NotFoundOrDenied,
6179    TooLarge,
6180    Deadline,
6181    Internal,
6182    // The host decoded the envelope but its declared `build_stamp` did not
6183    // match this side's own `BUILD_STAMP` (see `HarnessReadApiError::
6184    // BuildStampMismatch`, which this carries verbatim onto the wire so the
6185    // caller sees both stamps instead of the generic `InvalidRequest` every
6186    // other malformed-envelope shape collapses to). Reaching this variant
6187    // itself proves a build skew between the two loopback sides -- this
6188    // protocol has exactly one accepted build stamp, so a well-formed peer
6189    // never triggers it against a matching one.
6190    BuildStampMismatch { expected: String, received: String },
6191}
6192
6193#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6194#[serde(deny_unknown_fields)]
6195pub struct SessionContextV1 {
6196    pub grant_id: SessionGrantId,
6197    pub grant_revision: HarnessRevision,
6198    pub actor_run: CallerRunV1,
6199    /// The calling run's own task, redacted the same way as `TaskGet` --
6200    /// present only when the grant's task scope makes it visible (mirrors
6201    /// `actor_run.task_id`).
6202    pub task: Option<RedactedTaskV1>,
6203    /// Every OTHER run recorded against `task` ("what previous sessions did
6204    /// on it"), redacted the same way as `RunGet`. Gated solely by `task`
6205    /// being visible -- independent of the grant's `runs` read scope, which
6206    /// governs the general run-browsing tools instead. Always empty when
6207    /// `task` is `None`; never includes runs of another task.
6208    pub sibling_runs: Vec<RedactedRunV1>,
6209    pub read_permissions: HarnessReadPermissionsV1,
6210    pub monitoring_visibility: HarnessMonitoringVisibilityV1,
6211    /// Observation, not a budget: the number of tasks parented directly
6212    /// under the calling run's own task right now
6213    /// (`HarnessEngine::task_child_count`), `0` if it has no children.
6214    /// Unconditional -- computed from `actor_run`'s own task the same way
6215    /// the retired `maximum_child_count` wire field it replaces was
6216    /// (independent of whether `task` above is redacted to `None`). That
6217    /// retired field stated a cap that was validated for range but compared
6218    /// against no actual child count anywhere in the service -- a session
6219    /// could read a limit here and still create children past it. The
6220    /// owner's ruling: do not cap a session's children, detect them. Nothing
6221    /// in this wire enforces an upper bound on this count.
6222    pub child_task_count: u64,
6223    /// Observation, not a budget: the depth of the deepest chain of tasks
6224    /// parented (transitively) under the calling run's own task right now
6225    /// (`HarnessEngine::task_subtree_depth`) -- `0` if it has no children,
6226    /// `1` if it has children but no grandchildren, and so on. Unconditional
6227    /// in the same sense as `child_task_count` above. Replaces the retired
6228    /// `maximum_child_depth` wire field for the same reason `child_task_
6229    /// count` replaces `maximum_child_count` (see that field's doc comment).
6230    /// Nothing in this wire enforces an upper bound on this depth.
6231    pub child_task_subtree_depth: u64,
6232    /// D5, Slice D: mirrors `grant.task_permissions.create` -- gates
6233    /// `g4a_task_create` in `allowed_tool_ids`.
6234    pub task_create: bool,
6235    /// D5, Slice D: mirrors `grant.task_permissions.mutate` -- gates
6236    /// `g4a_task_move` in `allowed_tool_ids`.
6237    pub task_mutate: bool,
6238    pub allowed_tool_ids: Vec<String>,
6239    pub history_message_count: Option<u64>,
6240    pub completed_turn_count: Option<u64>,
6241    pub total_tokens: Option<u64>,
6242}
6243
6244impl SessionContextV1 {
6245    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6246        self.grant_id.validate().map_err(HarnessReadApiError::Protocol)?;
6247        self.grant_revision.validate().map_err(HarnessReadApiError::Protocol)?;
6248        self.actor_run.validate()?;
6249        match &self.task {
6250            Some(task) => {
6251                task.validate()?;
6252                if self.actor_run.task_id.as_ref() != Some(&task.task_id) {
6253                    return Err(HarnessReadApiError::InvalidContextTask);
6254                }
6255            }
6256            None if !self.sibling_runs.is_empty() => {
6257                return Err(HarnessReadApiError::InvalidContextTask);
6258            }
6259            None => {}
6260        }
6261        if self.sibling_runs.len() > HARNESS_LINKS_MAX
6262            || self.sibling_runs.windows(2).any(|pair| pair[0].run_id >= pair[1].run_id)
6263            || self.sibling_runs.iter().any(|run| {
6264                run.run_id == self.actor_run.run_id
6265                    || run.task_id.as_ref() != self.actor_run.task_id.as_ref()
6266            })
6267        {
6268            return Err(HarnessReadApiError::InvalidContextTask);
6269        }
6270        for run in &self.sibling_runs {
6271            run.validate()?;
6272        }
6273        self.read_permissions.validate().map_err(HarnessReadApiError::Protocol)?;
6274        if self.allowed_tool_ids.len() > HARNESS_READ_TOOL_IDS.len() + HARNESS_WRITE_TOOL_IDS.len()
6275            || self.allowed_tool_ids.windows(2).any(|pair| pair[0] >= pair[1])
6276            || self.allowed_tool_ids.iter().any(|id| {
6277                !HARNESS_READ_TOOL_IDS.contains(&id.as_str())
6278                    && !HARNESS_WRITE_TOOL_IDS.contains(&id.as_str())
6279            })
6280            || self.allowed_tool_ids != expected_allowed_tool_ids(self)
6281        {
6282            return Err(HarnessReadApiError::InvalidAllowedTools);
6283        }
6284        Ok(())
6285    }
6286}
6287
6288fn expected_allowed_tool_ids(context: &SessionContextV1) -> Vec<String> {
6289    // S10: `g4a_run_finish` is unconditional, exactly like `g4a_context_get`
6290    // -- see `HARNESS_WRITE_TOOL_IDS`'s own doc comment for why it carries
6291    // no grant-permission gate at all.
6292    let mut tools = vec!["g4a_context_get", "g4a_run_finish"];
6293    if context.monitoring_visibility != HarnessMonitoringVisibilityV1::None {
6294        tools.push("g4a_monitor_get");
6295    }
6296    if context.monitoring_visibility == HarnessMonitoringVisibilityV1::Timeline {
6297        tools.push("g4a_timeline_read");
6298    }
6299    if context.read_permissions.tasks != HarnessEntityReadScopeV1::None {
6300        tools.extend(["g4a_tasks_get", "g4a_tasks_list"]);
6301    }
6302    if context.read_permissions.runs != HarnessEntityReadScopeV1::None {
6303        tools.extend(["g4a_runs_get", "g4a_runs_list"]);
6304    }
6305    if context.read_permissions.operations != HarnessEntityReadScopeV1::None {
6306        tools.push("g4a_operation_get");
6307    }
6308    if context.task_create {
6309        tools.push("g4a_task_create");
6310    }
6311    if context.task_mutate {
6312        tools.push("g4a_task_move");
6313    }
6314    tools.sort_unstable();
6315    tools.into_iter().map(str::to_owned).collect()
6316}
6317
6318#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6319#[serde(deny_unknown_fields)]
6320pub struct CallerRunV1 {
6321    pub run_id: HarnessRunId,
6322    pub task_id: Option<HarnessTaskId>,
6323    pub parent_run_id: Option<HarnessRunId>,
6324    pub lifecycle: HarnessRunLifecycleV1,
6325    pub references_redacted: bool,
6326}
6327
6328impl CallerRunV1 {
6329    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6330        self.run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6331        if let Some(task_id) = &self.task_id {
6332            task_id.validate().map_err(HarnessReadApiError::Protocol)?;
6333        }
6334        if let Some(parent_run_id) = &self.parent_run_id {
6335            parent_run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6336        }
6337        Ok(())
6338    }
6339}
6340
6341#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6342#[serde(rename_all = "kebab-case")]
6343pub enum ProjectionAvailabilityV1 {
6344    Unknown,
6345    NotObserved,
6346    Current,
6347    Partial,
6348    Frozen,
6349}
6350
6351#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6352#[serde(rename_all = "kebab-case")]
6353pub enum ProjectionFreshnessV1 {
6354    Unavailable,
6355    Live,
6356    Stale,
6357    IncompleteAfterGap,
6358    LastKnown,
6359    ReplacedIncarnation,
6360}
6361
6362#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6363#[serde(rename_all = "kebab-case")]
6364pub enum FeatureObservationStateV1 {
6365    Unknown,
6366    NotSupportedByObservedSources,
6367    SupportedNotObserved,
6368    Observed,
6369}
6370
6371#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6372#[serde(deny_unknown_fields)]
6373pub struct MonitorFeatureStatesV1 {
6374    pub todo: FeatureObservationStateV1,
6375    pub tools: FeatureObservationStateV1,
6376    pub subagents: FeatureObservationStateV1,
6377    pub interactions: FeatureObservationStateV1,
6378    pub owned_processes: FeatureObservationStateV1,
6379    pub files: FeatureObservationStateV1,
6380    pub usage: FeatureObservationStateV1,
6381    pub history: FeatureObservationStateV1,
6382}
6383
6384#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6385#[serde(deny_unknown_fields)]
6386pub struct SessionMonitorV1 {
6387    pub run_id: HarnessRunId,
6388    pub visibility: HarnessMonitoringVisibilityV1,
6389    pub availability: ProjectionAvailabilityV1,
6390    pub freshness: ProjectionFreshnessV1,
6391    pub transport_incomplete: bool,
6392    pub features: MonitorFeatureStatesV1,
6393    pub todo_total: u16,
6394    pub todo_completed: u16,
6395    pub active_tools: u16,
6396    pub active_subagents: u16,
6397    pub active_interactions: u16,
6398    /// Count of `ObservationKindV1::ActionBlocked` observations recorded
6399    /// against this session so far (`SessionProjection::blocked_count`,
6400    /// saturated to `u16`) -- NOT a "currently pending" count like its
6401    /// `active_*` siblings: unlike a tool call or an interaction, a block
6402    /// has no resolution step to wait on (see that observation kind's own
6403    /// doc comment), so there is no pending/resolved split to count
6404    /// separately. Mirrors the fleet-wide `HarnessRuntimeManagedSessionV1::
6405    /// blocked_count` this same tally already backs, just for this one run.
6406    /// `#[serde(default)]`: additive field on a read-wire response that is
6407    /// never persisted, only built fresh per read.
6408    #[serde(default)]
6409    pub active_blocks: u16,
6410    pub active_processes: u16,
6411    pub input_tokens: u64,
6412    pub output_tokens: u64,
6413    pub cache_read_tokens: u64,
6414    pub cache_write_tokens: u64,
6415    pub reasoning_tokens: u64,
6416    pub context_window_tokens: Option<u64>,
6417    #[serde(default, skip_serializing_if = "Option::is_none")]
6418    pub history: Option<SessionMonitorHistoryV1>,
6419    pub detail: Option<SessionMonitorDetailV1>,
6420}
6421
6422impl SessionMonitorV1 {
6423    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6424        self.run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6425        if self.todo_completed > self.todo_total {
6426            return Err(HarnessReadApiError::InvalidMonitorCounts);
6427        }
6428        let detail_allowed = matches!(
6429            self.visibility,
6430            HarnessMonitoringVisibilityV1::Detail | HarnessMonitoringVisibilityV1::Timeline
6431        );
6432        if self.detail.is_some() && !detail_allowed {
6433            return Err(HarnessReadApiError::InvalidMonitorDetail);
6434        }
6435        if matches!(self.availability, ProjectionAvailabilityV1::Unknown | ProjectionAvailabilityV1::NotObserved)
6436            && self.detail.is_some()
6437        {
6438            return Err(HarnessReadApiError::InvalidMonitorDetail);
6439        }
6440        if self.freshness == ProjectionFreshnessV1::IncompleteAfterGap
6441            && !self.transport_incomplete
6442        {
6443            return Err(HarnessReadApiError::InvalidMonitorDetail);
6444        }
6445        if let Some(detail) = &self.detail {
6446            detail.validate(&self.features)?;
6447        }
6448        if self.features.history != FeatureObservationStateV1::Observed
6449            && self.history.is_some()
6450        {
6451            return Err(HarnessReadApiError::InvalidMonitorDetail);
6452        }
6453        validate_feature_counts(self)?;
6454        Ok(())
6455    }
6456
6457    pub fn validate_for(&self, run_id: &HarnessRunId) -> Result<(), HarnessReadApiError> {
6458        self.validate()?;
6459        if &self.run_id != run_id {
6460            return Err(HarnessReadApiError::InvalidRunState);
6461        }
6462        Ok(())
6463    }
6464}
6465
6466#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6467#[serde(deny_unknown_fields)]
6468pub struct SessionMonitorHistoryV1 {
6469    pub message_count: u64,
6470    pub message_count_exact: bool,
6471    pub completed_turn_count: Option<u64>,
6472    pub total_tokens: Option<u64>,
6473}
6474
6475#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6476#[serde(deny_unknown_fields)]
6477pub struct SessionMonitorDetailV1 {
6478    pub todo_facts: Vec<TodoFactV1>,
6479    pub tool_facts: Vec<ActivityFactV1>,
6480    pub subagent_facts: Vec<ActivityFactV1>,
6481    pub interaction_facts: Vec<InteractionFactV1>,
6482    /// One fact per `ObservationKindV1::ActionBlocked` observation recorded
6483    /// against this session. `hatchery-harness-service::read::monitor_detail`
6484    /// derives these from the session's own bounded `timeline`
6485    /// (`hatchery_observation_engine::SessionProjection::timeline`), not
6486    /// from a dedicated correlation-tracked list: a block has no
6487    /// request/response pairing for that engine's projection to track, only
6488    /// a running `blocked_count` tally (see `SessionMonitorV1::active_blocks`).
6489    /// Its own list rather than folded into `interaction_facts`:
6490    /// `interaction_facts` is built strictly off `SessionProjection::
6491    /// interactions`, which `ActionBlocked` never populates, so folding it
6492    /// in would need widening that engine-side projection, out of this
6493    /// field's own scope. `#[serde(default)]` reads a monitor response
6494    /// captured before this field existed as empty rather than failing to
6495    /// deserialize -- this type is never persisted, only built fresh per
6496    /// read and sent over the operator/grant-bound wire.
6497    #[serde(default)]
6498    pub block_facts: Vec<BlockFactV1>,
6499    pub process_facts: Vec<ActivityFactV1>,
6500    pub file_facts: Vec<FileFactV1>,
6501}
6502
6503impl SessionMonitorDetailV1 {
6504    pub fn validate(&self, features: &MonitorFeatureStatesV1) -> Result<(), HarnessReadApiError> {
6505        validate_monitor_facts(&self.todo_facts)?;
6506        validate_monitor_facts(&self.tool_facts)?;
6507        validate_monitor_facts(&self.subagent_facts)?;
6508        validate_monitor_facts(&self.interaction_facts)?;
6509        validate_monitor_facts(&self.block_facts)?;
6510        validate_monitor_facts(&self.process_facts)?;
6511        validate_monitor_facts(&self.file_facts)?;
6512        for fact in &self.todo_facts { fact.validate()?; }
6513        for fact in &self.tool_facts { fact.validate()?; }
6514        for fact in &self.subagent_facts { fact.validate()?; }
6515        for fact in &self.interaction_facts { fact.validate()?; }
6516        for fact in &self.block_facts { fact.validate()?; }
6517        for fact in &self.process_facts { fact.validate()?; }
6518        for fact in &self.file_facts { fact.validate()?; }
6519        if self.tool_facts.iter().any(|fact| fact.class != ActivityClassV1::Tool)
6520            || self.subagent_facts.iter().any(|fact| fact.class != ActivityClassV1::Subagent)
6521            || self.process_facts.iter().any(|fact| fact.class != ActivityClassV1::OwnedProcess)
6522        {
6523            return Err(HarnessReadApiError::InvalidMonitorDetail);
6524        }
6525        Ok(())
6526            .and_then(|_| require_observed_or_empty(features.todo, &self.todo_facts))
6527            .and_then(|_| require_observed_or_empty(features.tools, &self.tool_facts))
6528            .and_then(|_| require_observed_or_empty(features.subagents, &self.subagent_facts))
6529            .and_then(|_| require_observed_or_empty(features.interactions, &self.interaction_facts))
6530            .and_then(|_| require_observed_or_empty(features.owned_processes, &self.process_facts))
6531            .and_then(|_| require_observed_or_empty(features.files, &self.file_facts))
6532    }
6533}
6534
6535#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6536#[serde(deny_unknown_fields)]
6537pub struct TodoFactV1 {
6538    pub state: TodoStateV1,
6539    #[serde(default, skip_serializing_if = "Option::is_none")]
6540    pub todo_id: Option<String>,
6541    #[serde(default, skip_serializing_if = "Option::is_none")]
6542    pub label: Option<String>,
6543    pub evidence: ObservationEvidenceV1,
6544}
6545
6546impl TodoFactV1 {
6547    fn validate(&self) -> Result<(), HarnessReadApiError> {
6548        validate_optional_observation_text(
6549            "todo id",
6550            self.todo_id.as_deref(),
6551            HARNESS_OBSERVATION_LABEL_MAX_BYTES,
6552        )?;
6553        validate_optional_observation_text(
6554            "todo label",
6555            self.label.as_deref(),
6556            HARNESS_OBSERVATION_TODO_TEXT_MAX_BYTES,
6557        )
6558    }
6559}
6560
6561#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6562#[serde(rename_all = "kebab-case")]
6563pub enum TodoStateV1 { Pending, InProgress, Completed, Unknown }
6564
6565#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6566#[serde(deny_unknown_fields)]
6567pub struct ActivityFactV1 {
6568    pub class: ActivityClassV1,
6569    pub state: ActivityStateV1,
6570    #[serde(default, skip_serializing_if = "Option::is_none")]
6571    pub label: Option<String>,
6572    #[serde(default, skip_serializing_if = "Option::is_none")]
6573    pub correlation: Option<u16>,
6574    pub evidence: ObservationEvidenceV1,
6575}
6576
6577impl ActivityFactV1 {
6578    fn validate(&self) -> Result<(), HarnessReadApiError> {
6579        validate_optional_observation_text(
6580            "activity label",
6581            self.label.as_deref(),
6582            HARNESS_OBSERVATION_LABEL_MAX_BYTES,
6583        )?;
6584        validate_observation_correlation(self.correlation)
6585    }
6586}
6587
6588#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6589#[serde(rename_all = "kebab-case")]
6590pub enum ActivityClassV1 { Tool, Subagent, OwnedProcess }
6591
6592#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6593#[serde(rename_all = "kebab-case")]
6594pub enum ActivityStateV1 { Started, Active, Waiting, Completed, Failed, UnknownAfterGap }
6595
6596#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6597#[serde(deny_unknown_fields)]
6598pub struct InteractionFactV1 {
6599    pub class: InteractionClassV1,
6600    pub state: InteractionStateV1,
6601    #[serde(default, skip_serializing_if = "Option::is_none")]
6602    pub label: Option<String>,
6603    #[serde(default, skip_serializing_if = "Option::is_none")]
6604    pub correlation: Option<u16>,
6605    pub evidence: ObservationEvidenceV1,
6606}
6607
6608impl InteractionFactV1 {
6609    fn validate(&self) -> Result<(), HarnessReadApiError> {
6610        validate_optional_observation_text(
6611            "interaction label",
6612            self.label.as_deref(),
6613            HARNESS_OBSERVATION_LABEL_MAX_BYTES,
6614        )?;
6615        validate_observation_correlation(self.correlation)
6616    }
6617}
6618
6619#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6620#[serde(rename_all = "kebab-case")]
6621pub enum InteractionClassV1 { Attention, Approval, UserInput }
6622
6623#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6624#[serde(rename_all = "kebab-case")]
6625pub enum InteractionStateV1 { Required, Responded, Dismissed, UnknownAfterGap }
6626
6627#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6628#[serde(deny_unknown_fields)]
6629pub struct FileFactV1 {
6630    pub action: FileActionV1,
6631    #[serde(default, skip_serializing_if = "Option::is_none")]
6632    pub relative_path: Option<String>,
6633    pub evidence: ObservationEvidenceV1,
6634}
6635
6636impl FileFactV1 {
6637    fn validate(&self) -> Result<(), HarnessReadApiError> {
6638        if let Some(path) = self.relative_path.as_deref() {
6639            validate_observation_relative_path(path)?;
6640        }
6641        Ok(())
6642    }
6643}
6644
6645#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6646#[serde(rename_all = "kebab-case")]
6647pub enum FileActionV1 { Changed }
6648
6649/// One `ObservationKindV1::ActionBlocked` observation surfaced on the
6650/// monitor wire -- see `SessionMonitorDetailV1::block_facts`'s own doc
6651/// comment for why this is its own list rather than folded into
6652/// `interaction_facts`/`process_facts`. `label` is `<authority slug>:
6653/// <reason>`, truncated to `HARNESS_OBSERVATION_BLOCK_LABEL_MAX_BYTES` by
6654/// the producer -- never the raw, up-to-1024-byte `reason` verbatim.
6655#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6656#[serde(deny_unknown_fields)]
6657pub struct BlockFactV1 {
6658    pub state: BlockStateV1,
6659    #[serde(default, skip_serializing_if = "Option::is_none")]
6660    pub label: Option<String>,
6661    #[serde(default, skip_serializing_if = "Option::is_none")]
6662    pub correlation: Option<u16>,
6663    pub evidence: ObservationEvidenceV1,
6664}
6665
6666impl BlockFactV1 {
6667    fn validate(&self) -> Result<(), HarnessReadApiError> {
6668        validate_optional_observation_text(
6669            "block label",
6670            self.label.as_deref(),
6671            HARNESS_OBSERVATION_BLOCK_LABEL_MAX_BYTES,
6672        )?;
6673        validate_observation_correlation(self.correlation)
6674    }
6675}
6676
6677/// A single, always-terminal variant today (paralleling `FileActionV1::
6678/// Changed`) -- kept as its own enum rather than a bare unit field so a
6679/// future, more granular block state has somewhere to land without another
6680/// wire shape change.
6681#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6682#[serde(rename_all = "kebab-case")]
6683pub enum BlockStateV1 { Blocked }
6684
6685#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6686#[serde(rename_all = "kebab-case")]
6687pub enum ObservationEvidenceV1 {
6688    StructuredProvider,
6689    ManagedHook,
6690    WorkspaceObservation,
6691    NodeLifecycle,
6692    History,
6693    PtyHint,
6694}
6695
6696#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6697#[serde(deny_unknown_fields)]
6698pub struct TimelineEntryV1 {
6699    pub sequence: u64,
6700    pub received_at_ms: u64,
6701    pub category: TimelineCategoryV1,
6702    #[serde(default, skip_serializing_if = "Option::is_none")]
6703    pub label: Option<String>,
6704    #[serde(default)]
6705    pub state: TimelineStateV1,
6706    #[serde(default, skip_serializing_if = "Option::is_none")]
6707    pub correlation: Option<u16>,
6708    pub evidence: ObservationEvidenceV1,
6709}
6710
6711#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6712#[serde(deny_unknown_fields)]
6713pub struct TimelinePageV1 {
6714    pub run_id: HarnessRunId,
6715    pub availability: ProjectionAvailabilityV1,
6716    pub freshness: ProjectionFreshnessV1,
6717    pub transport_incomplete: bool,
6718    pub entries: Vec<TimelineEntryV1>,
6719    pub next_cursor: Option<u64>,
6720}
6721
6722impl TimelinePageV1 {
6723    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6724        self.run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6725        validate_bounded(&self.entries, HARNESS_TIMELINE_PAGE_LIMIT_MAX, TimelineEntryV1::validate)?;
6726        if self.entries.windows(2).any(|pair| pair[0].sequence >= pair[1].sequence)
6727            || self.next_cursor == Some(0)
6728        {
6729            return Err(HarnessReadApiError::InvalidCursor);
6730        }
6731        if self.next_cursor.is_some()
6732            && self.next_cursor != self.entries.last().map(|entry| entry.sequence)
6733        {
6734            return Err(HarnessReadApiError::InvalidCursor);
6735        }
6736        Ok(())
6737    }
6738
6739    pub fn validate_for(&self, run_id: &HarnessRunId) -> Result<(), HarnessReadApiError> {
6740        self.validate()?;
6741        if &self.run_id != run_id {
6742            return Err(HarnessReadApiError::InvalidRunState);
6743        }
6744        Ok(())
6745    }
6746}
6747
6748#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6749#[serde(deny_unknown_fields)]
6750pub struct TaskPageV1 {
6751    pub tasks: Vec<RedactedTaskV1>,
6752    pub next_cursor: Option<HarnessTaskId>,
6753}
6754
6755impl TaskPageV1 {
6756    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6757        validate_bounded(&self.tasks, HARNESS_ENTITY_PAGE_LIMIT_MAX, RedactedTaskV1::validate)?;
6758        if self.tasks.windows(2).any(|pair| pair[0].task_id >= pair[1].task_id)
6759            || self.next_cursor.as_ref() != self.tasks.last().map(|task| &task.task_id)
6760                && self.next_cursor.is_some()
6761        {
6762            return Err(HarnessReadApiError::InvalidCursor);
6763        }
6764        Ok(())
6765    }
6766}
6767
6768#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6769#[serde(deny_unknown_fields)]
6770pub struct RunPageV1 {
6771    pub runs: Vec<RedactedRunV1>,
6772    pub next_cursor: Option<HarnessRunId>,
6773}
6774
6775impl RunPageV1 {
6776    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6777        validate_bounded(&self.runs, HARNESS_ENTITY_PAGE_LIMIT_MAX, RedactedRunV1::validate)?;
6778        if self.runs.windows(2).any(|pair| pair[0].run_id >= pair[1].run_id)
6779            || self.next_cursor.as_ref() != self.runs.last().map(|run| &run.run_id)
6780                && self.next_cursor.is_some()
6781        {
6782            return Err(HarnessReadApiError::InvalidCursor);
6783        }
6784        Ok(())
6785    }
6786}
6787
6788impl TimelineEntryV1 {
6789    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6790        if self.sequence == 0 || self.received_at_ms == 0 {
6791            return Err(HarnessReadApiError::InvalidTimelineEntry);
6792        }
6793        validate_optional_observation_text(
6794            "timeline label",
6795            self.label.as_deref(),
6796            HARNESS_OBSERVATION_PATH_MAX_BYTES,
6797        )?;
6798        validate_observation_correlation(self.correlation)?;
6799        Ok(())
6800    }
6801}
6802
6803#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
6804#[serde(rename_all = "kebab-case")]
6805pub enum TimelineStateV1 {
6806    Started,
6807    Active,
6808    Waiting,
6809    Required,
6810    Updated,
6811    Changed,
6812    Completed,
6813    Failed,
6814    Dismissed,
6815    Interrupted,
6816    Stale,
6817    UnknownAfterGap,
6818    #[default]
6819    Unknown,
6820}
6821
6822#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6823#[serde(rename_all = "kebab-case")]
6824pub enum TimelineCategoryV1 {
6825    Lifecycle,
6826    Todo,
6827    Tool,
6828    Subagent,
6829    Interaction,
6830    Process,
6831    File,
6832    Usage,
6833    History,
6834}
6835
6836#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6837#[serde(deny_unknown_fields)]
6838pub struct RedactedTaskV1 {
6839    pub task_id: HarnessTaskId,
6840    pub revision: HarnessRevision,
6841    pub title: String,
6842    pub body: String,
6843    pub creator: TaskCreatorCategoryV1,
6844    pub parent_task_id: Option<HarnessTaskId>,
6845    pub dependency_ids: Vec<HarnessTaskId>,
6846    pub state: HarnessTaskStateV1,
6847    pub run_ids: Vec<HarnessRunId>,
6848    pub references_redacted: bool,
6849    pub result_refs: Vec<HarnessResultRef>,
6850    pub artifact_refs: Vec<HarnessArtifactRef>,
6851    pub created_at_unix_ms: u64,
6852    pub updated_at_unix_ms: u64,
6853}
6854
6855impl RedactedTaskV1 {
6856    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6857        self.task_id.validate().map_err(HarnessReadApiError::Protocol)?;
6858        self.revision.validate().map_err(HarnessReadApiError::Protocol)?;
6859        validate_text("title", &self.title, HARNESS_TITLE_MAX_BYTES, false)?;
6860        validate_text("body", &self.body, HARNESS_BODY_MAX_BYTES, true)?;
6861        if let Some(parent) = &self.parent_task_id {
6862            parent.validate().map_err(HarnessReadApiError::Protocol)?;
6863        }
6864        validate_bounded_ids_with_max(&self.dependency_ids, HARNESS_DEPENDENCIES_MAX)?;
6865        validate_bounded_ids_with_max(&self.run_ids, HARNESS_LINKS_MAX)?;
6866        validate_bounded_ids_with_max(&self.result_refs, HARNESS_RESULTS_MAX)?;
6867        validate_bounded_ids_with_max(&self.artifact_refs, HARNESS_ARTIFACTS_MAX)?;
6868        validate_timestamps(self.created_at_unix_ms, self.updated_at_unix_ms)
6869    }
6870}
6871
6872#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6873#[serde(rename_all = "kebab-case")]
6874pub enum TaskCreatorCategoryV1 { User, ParentRun }
6875
6876#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6877#[serde(rename_all = "kebab-case")]
6878pub enum RedactedWorktreeIntentV1 { Existing, Managed }
6879
6880#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6881#[serde(deny_unknown_fields)]
6882pub struct RedactedRunIntentV1 {
6883    pub mode: HarnessExecutionModeV1,
6884    pub worktree: RedactedWorktreeIntentV1,
6885    pub has_delivery_bundle: bool,
6886    pub has_continuation: bool,
6887}
6888
6889#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6890#[serde(deny_unknown_fields)]
6891pub struct RedactedRunV1 {
6892    pub run_id: HarnessRunId,
6893    pub revision: HarnessRevision,
6894    pub parent_run_id: Option<HarnessRunId>,
6895    pub task_id: Option<HarnessTaskId>,
6896    pub operation_id: Option<HarnessOperationId>,
6897    pub intent: RedactedRunIntentV1,
6898    pub lifecycle: HarnessRunLifecycleV1,
6899    pub binding: RedactedBindingStateV1,
6900    pub result_disposition: Option<HarnessResultDispositionV1>,
6901    pub failure_category: Option<HarnessFailureCategoryV1>,
6902    pub context_pack: Option<HarnessResolvedContextPackReceiptV1>,
6903    pub git_facts: Option<HarnessRunGitFactsV1>,
6904    /// Opaque `hreceipt_…` ref copied from the run record. The engine
6905    /// requires it to equal the committed delivery receipt's `receipt_ref`.
6906    /// The receipt body stays on the transfer. Older payloads that omit the
6907    /// field deserialize as `None`.
6908    #[serde(default)]
6909    pub delivery_receipt: Option<HarnessReceiptRef>,
6910    pub references_redacted: bool,
6911    pub created_at_unix_ms: u64,
6912    pub updated_at_unix_ms: u64,
6913}
6914
6915impl RedactedRunV1 {
6916    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
6917        self.run_id.validate().map_err(HarnessReadApiError::Protocol)?;
6918        self.revision.validate().map_err(HarnessReadApiError::Protocol)?;
6919        if let Some(parent) = &self.parent_run_id {
6920            parent.validate().map_err(HarnessReadApiError::Protocol)?;
6921        }
6922        if let Some(task_id) = &self.task_id {
6923            task_id.validate().map_err(HarnessReadApiError::Protocol)?;
6924        }
6925        if let Some(operation_id) = &self.operation_id {
6926            operation_id.validate().map_err(HarnessReadApiError::Protocol)?;
6927        }
6928        if matches!(self.lifecycle, HarnessRunLifecycleV1::Running | HarnessRunLifecycleV1::Waiting)
6929            && self.binding == RedactedBindingStateV1::None
6930        {
6931            return Err(HarnessReadApiError::InvalidRunState);
6932        }
6933        if matches!(self.lifecycle, HarnessRunLifecycleV1::Failed) != self.failure_category.is_some() {
6934            return Err(HarnessReadApiError::InvalidRunState);
6935        }
6936        if let Some(pack) = &self.context_pack {
6937            pack.validate().map_err(HarnessReadApiError::Protocol)?;
6938        }
6939        if let Some(facts) = &self.git_facts {
6940            facts.validate().map_err(HarnessReadApiError::Protocol)?;
6941        }
6942        if let Some(receipt) = &self.delivery_receipt {
6943            receipt.validate().map_err(HarnessReadApiError::Protocol)?;
6944        }
6945        validate_timestamps(self.created_at_unix_ms, self.updated_at_unix_ms)
6946    }
6947}
6948
6949#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
6950#[serde(rename_all = "kebab-case")]
6951pub enum RedactedBindingStateV1 { None, ManagedDormant, ManagedActive, Inline }
6952
6953/// D5, Slice D's operator-side per-task operations ledger entry
6954/// (`HarnessOperatorRequestV1::TaskOperations` /
6955/// `HarnessOperatorResponseV1::TaskOperations`), one row per
6956/// `HarnessEngine::operations_for_task` entry. Unlike `RedactedOperationV1`,
6957/// `actor` here is the RAW `HarnessActorV1` -- the operator is already
6958/// trusted with every other raw identity this wire exposes (session
6959/// records, node/workspace ids, ...), and this ledger is the audit trail
6960/// the plan promises ("the operator's ledger on T1 shows A's run as the
6961/// actor of both writes"). The agent side never gets this: `g4a_tasks_get`
6962/// keeps `RedactedTaskV1`'s existing category-only exposure.
6963#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6964#[serde(deny_unknown_fields)]
6965pub struct HarnessOperationLedgerEntryV1 {
6966    pub operation_id: HarnessOperationId,
6967    pub created_at_unix_ms: u64,
6968    pub kind: HarnessOperationKindV1,
6969    pub actor: HarnessActorV1,
6970}
6971
6972impl HarnessOperationLedgerEntryV1 {
6973    pub fn validate(&self) -> Result<(), HarnessOperatorApiError> {
6974        self.operation_id.validate().map_err(HarnessOperatorApiError::Protocol)?;
6975        self.actor.validate().map_err(HarnessOperatorApiError::Protocol)?;
6976        if self.created_at_unix_ms == 0 {
6977            return Err(HarnessOperatorApiError::InvalidOperationLedger);
6978        }
6979        Ok(())
6980    }
6981}
6982
6983#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6984#[serde(deny_unknown_fields)]
6985pub struct RedactedOperationV1 {
6986    pub operation_id: HarnessOperationId,
6987    pub revision: HarnessRevision,
6988    pub kind: HarnessOperationKindV1,
6989    pub state: HarnessOperationStateV1,
6990    pub task_id: Option<HarnessTaskId>,
6991    pub run_id: Option<HarnessRunId>,
6992    pub reconciles_operation_id: Option<HarnessOperationId>,
6993    pub references_redacted: bool,
6994    pub failure_category: Option<HarnessFailureCategoryV1>,
6995    pub outcome_unknown_reason: Option<HarnessOutcomeUnknownReasonV1>,
6996    pub reconciliation_outcome: Option<HarnessReconciliationOutcomeV1>,
6997    pub created_at_unix_ms: u64,
6998    pub updated_at_unix_ms: u64,
6999    pub dispatched_at_unix_ms: Option<u64>,
7000    pub finished_at_unix_ms: Option<u64>,
7001}
7002
7003impl RedactedOperationV1 {
7004    pub fn validate(&self) -> Result<(), HarnessReadApiError> {
7005        self.operation_id.validate().map_err(HarnessReadApiError::Protocol)?;
7006        self.revision.validate().map_err(HarnessReadApiError::Protocol)?;
7007        if let Some(task_id) = &self.task_id {
7008            task_id.validate().map_err(HarnessReadApiError::Protocol)?;
7009        }
7010        if let Some(run_id) = &self.run_id {
7011            run_id.validate().map_err(HarnessReadApiError::Protocol)?;
7012        }
7013        if let Some(operation_id) = &self.reconciles_operation_id {
7014            operation_id.validate().map_err(HarnessReadApiError::Protocol)?;
7015        }
7016        if (self.state == HarnessOperationStateV1::Failed) != self.failure_category.is_some()
7017            || (self.state == HarnessOperationStateV1::OutcomeUnknown)
7018                != self.outcome_unknown_reason.is_some()
7019            || (self.state == HarnessOperationStateV1::Reconciled)
7020                != self.reconciliation_outcome.is_some()
7021        {
7022            return Err(HarnessReadApiError::InvalidOperationState);
7023        }
7024        validate_timestamps(self.created_at_unix_ms, self.updated_at_unix_ms)?;
7025        for timestamp in [self.dispatched_at_unix_ms, self.finished_at_unix_ms].into_iter().flatten() {
7026            if timestamp < self.created_at_unix_ms || timestamp > self.updated_at_unix_ms {
7027                return Err(HarnessReadApiError::InvalidTimestamp);
7028            }
7029        }
7030        Ok(())
7031    }
7032}
7033
7034#[derive(Debug, Error)]
7035pub enum HarnessReadApiError {
7036    #[error(
7037        "build stamp mismatch: this side was built from tree {expected}, the peer from \
7038         tree {received} -- rebuild and restart the out-of-date side (hatchery-harness \
7039         and its hatchery-harness-client-based peers are built from the same tree and \
7040         must be rolled together)"
7041    )]
7042    BuildStampMismatch { expected: String, received: String },
7043    #[error("harness read credential is malformed")]
7044    MalformedCredential,
7045    #[error("harness read limit is outside the supported range")]
7046    InvalidLimit,
7047    #[error("harness read cursor is invalid")]
7048    InvalidCursor,
7049    #[error("harness read context has invalid allowed tools")]
7050    InvalidAllowedTools,
7051    #[error("harness monitor counts are inconsistent")]
7052    InvalidMonitorCounts,
7053    #[error("harness monitor detail is inconsistent with visibility or availability")]
7054    InvalidMonitorDetail,
7055    #[error("harness timeline entry is invalid")]
7056    InvalidTimelineEntry,
7057    #[error("harness read collection is not canonical or bounded")]
7058    InvalidCollection,
7059    #[error("harness read timestamp is invalid")]
7060    InvalidTimestamp,
7061    #[error("harness redacted run state is invalid")]
7062    InvalidRunState,
7063    #[error("harness redacted operation state is invalid")]
7064    InvalidOperationState,
7065    #[error("harness context task or sibling runs are inconsistent with the actor run")]
7066    InvalidContextTask,
7067    #[error("harness read text field is invalid: {0}")]
7068    InvalidText(&'static str),
7069    #[error("harness task create request is invalid")]
7070    InvalidTaskCreate,
7071    #[error("harness protocol value is invalid: {0}")]
7072    Protocol(#[from] hatchery_harness_protocol::HarnessValidationError),
7073}
7074
7075#[derive(Debug, Error)]
7076pub enum HarnessOperatorApiError {
7077    #[error(
7078        "build stamp mismatch: this side was built from tree {expected}, the peer from \
7079         tree {received} -- rebuild and restart the out-of-date side \
7080         (hatchery-harness and its hatchery-tui / hatchery-tui-light client are built \
7081         from the same tree and must be rolled together)"
7082    )]
7083    BuildStampMismatch { expected: String, received: String },
7084    #[error("harness operator credential is malformed")]
7085    MalformedCredential,
7086    #[error("harness operator limit is outside the supported range")]
7087    InvalidLimit,
7088    #[error("harness operator cursor is invalid")]
7089    InvalidCursor,
7090    #[error("harness operator request reference is malformed")]
7091    MalformedRequestRef,
7092    #[error("harness operator submission timestamp is invalid")]
7093    InvalidSubmittedAt,
7094    #[error("harness runtime inventory is invalid")]
7095    InvalidRuntimeInventory,
7096    #[error("harness run correlation is invalid")]
7097    InvalidRunCorrelation,
7098    #[error("harness run transfer summary is invalid")]
7099    InvalidRunTransfer,
7100    #[error("harness reverse attribution is invalid")]
7101    InvalidReverseAttribution,
7102    #[error("harness run context source observation is invalid")]
7103    InvalidRunContextSourceObservation,
7104    #[error("harness run workspace origin is invalid")]
7105    InvalidWorkspaceOrigin,
7106    #[error("harness repository-relative path is invalid")]
7107    InvalidRepositoryPath,
7108    #[error("harness run workspace tree is invalid")]
7109    InvalidWorkspaceTree,
7110    #[error("harness run workspace file is invalid")]
7111    InvalidWorkspaceFile,
7112    #[error("harness git status is invalid")]
7113    InvalidGitStatus,
7114    #[error("harness git summary is invalid")]
7115    InvalidGitSummary,
7116    #[error("harness git object id is invalid")]
7117    InvalidGitObjectId,
7118    #[error("harness git history is invalid")]
7119    InvalidGitHistory,
7120    #[error("harness git diff is invalid")]
7121    InvalidGitDiff,
7122    #[error("harness launch plan catalog is invalid")]
7123    InvalidLaunchPlans,
7124    #[error("harness task launch options are invalid")]
7125    InvalidTaskLaunchOptions,
7126    #[error("harness reviewed task launch selection is invalid")]
7127    InvalidTaskLaunchSelection,
7128    #[error("harness native history value is invalid")]
7129    InvalidNativeHistory,
7130    #[error("harness terminal page is invalid")]
7131    InvalidTerminalPage,
7132    #[error("harness session spawn request is invalid")]
7133    InvalidSessionSpawn,
7134    #[error("harness session control request is invalid")]
7135    InvalidSessionControl,
7136    #[error("harness agent stream content is invalid")]
7137    InvalidAgentStream,
7138    #[error("harness session record request is invalid")]
7139    InvalidSessionRecordRequest,
7140    #[error("harness host path is invalid")]
7141    InvalidHostPath,
7142    #[error("harness host directory browse request is invalid")]
7143    InvalidHostDirectoryBrowseRequest,
7144    #[error("harness workspace or worktree resource mutation request is invalid")]
7145    InvalidResourceMutationRequest,
7146    #[error("harness task operations ledger entry is invalid")]
7147    InvalidOperationLedger,
7148    #[error("harness operator response is invalid")]
7149    Read(#[source] HarnessReadApiError),
7150    #[error("harness protocol value is invalid: {0}")]
7151    Protocol(#[source] hatchery_harness_protocol::HarnessValidationError),
7152}
7153
7154fn validate_credential(value: &str) -> Result<(), HarnessReadApiError> {
7155    if value.len() > HARNESS_READ_CREDENTIAL_MAX_BYTES || !value.starts_with(TOKEN_PREFIX) {
7156        return Err(HarnessReadApiError::MalformedCredential);
7157    }
7158    let (payload, proof) = value[TOKEN_PREFIX.len()..]
7159        .split_once('.')
7160        .ok_or(HarnessReadApiError::MalformedCredential)?;
7161    if payload.is_empty()
7162        || payload.len() % 2 != 0
7163        || proof.len() != 64
7164        || !payload.bytes().chain(proof.bytes()).all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
7165    {
7166        return Err(HarnessReadApiError::MalformedCredential);
7167    }
7168    Ok(())
7169}
7170
7171fn validate_operator_credential(value: &str) -> Result<(), HarnessOperatorApiError> {
7172    let payload = value.strip_prefix(OPERATOR_TOKEN_PREFIX)
7173        .ok_or(HarnessOperatorApiError::MalformedCredential)?;
7174    if value.len() > HARNESS_OPERATOR_CREDENTIAL_MAX_BYTES
7175        || payload.len() != 64
7176        || !payload.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
7177    {
7178        return Err(HarnessOperatorApiError::MalformedCredential);
7179    }
7180    Ok(())
7181}
7182
7183fn validate_operator_request_ref(value: &str) -> Result<(), HarnessOperatorApiError> {
7184    let payload = value.strip_prefix(OPERATOR_REQUEST_REF_PREFIX)
7185        .ok_or(HarnessOperatorApiError::MalformedRequestRef)?;
7186    if payload.len() != 24
7187        || !payload.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
7188    {
7189        return Err(HarnessOperatorApiError::MalformedRequestRef);
7190    }
7191    Ok(())
7192}
7193
7194fn valid_runtime_id(value: &str, maximum: usize) -> bool {
7195    !value.is_empty()
7196        && value.len() <= maximum
7197        && value.is_ascii()
7198        && value.bytes().all(|byte| {
7199            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'@' | b'+')
7200        })
7201}
7202
7203fn is_lower_hex(byte: u8) -> bool {
7204    byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
7205}
7206
7207fn valid_sha256_digest(value: &str) -> bool {
7208    value.strip_prefix("sha256:")
7209        .is_some_and(|digest| digest.len() == 64 && digest.bytes().all(is_lower_hex))
7210}
7211
7212fn validate_node_workspace_route(
7213    node_id: &str,
7214    workspace_id: &str,
7215) -> Result<(), HarnessOperatorApiError> {
7216    if !valid_runtime_id(node_id, 128) || !valid_runtime_id(workspace_id, 128) {
7217        return Err(HarnessOperatorApiError::InvalidWorkspaceOrigin);
7218    }
7219    Ok(())
7220}
7221
7222fn validate_repository_path(value: &str) -> Result<(), HarnessOperatorApiError> {
7223    if value.is_empty()
7224        || value.len() > HARNESS_REPOSITORY_PATH_MAX_BYTES
7225        || value.starts_with('/')
7226        || value.contains('\\')
7227        || value.contains(':')
7228        || value.chars().any(char::is_control)
7229        || value.split('/').any(|component| {
7230            component.is_empty() || matches!(component, "." | "..")
7231        })
7232    {
7233        return Err(HarnessOperatorApiError::InvalidRepositoryPath);
7234    }
7235    Ok(())
7236}
7237
7238fn validate_git_object_id(value: &str) -> Result<(), HarnessOperatorApiError> {
7239    if !matches!(value.len(), 40 | 64) || !value.bytes().all(is_lower_hex) {
7240        return Err(HarnessOperatorApiError::InvalidGitObjectId);
7241    }
7242    Ok(())
7243}
7244
7245fn validate_git_single_line(
7246    value: &str,
7247    maximum: usize,
7248    required: bool,
7249) -> Result<(), HarnessOperatorApiError> {
7250    if value.len() > maximum
7251        || required && value.is_empty()
7252        || value.chars().any(char::is_control)
7253    {
7254        return Err(HarnessOperatorApiError::InvalidGitSummary);
7255    }
7256    Ok(())
7257}
7258
7259fn validate_native_session_catalog_limit(limit: u16) -> Result<(), HarnessOperatorApiError> {
7260    if !(1..=HARNESS_NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
7261        return Err(HarnessOperatorApiError::InvalidLimit);
7262    }
7263    Ok(())
7264}
7265
7266fn valid_native_selection_id(value: &str) -> bool {
7267    !value.trim().is_empty()
7268        && value.len() <= 1_024
7269        && value.bytes().all(|byte| {
7270            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
7271        })
7272}
7273
7274fn valid_native_group_id(value: &str) -> bool {
7275    !value.trim().is_empty()
7276        && value.len() <= 128
7277        && value.bytes().all(|byte| {
7278            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
7279        })
7280}
7281
7282fn valid_native_single_line(value: &str, maximum: usize, required: bool) -> bool {
7283    (!required || !value.trim().is_empty())
7284        && value.len() <= maximum
7285        && !value.chars().any(char::is_control)
7286}
7287
7288/// Mirrors `gate4agent-node-protocol`'s own `contains_unsafe_control_bytes`:
7289/// every control character is unsafe except the three ACP content is allowed
7290/// to carry verbatim (`\n`, `\r`, `\t`), itself mirroring
7291/// `gate4agent_types::control::validate_text`'s own allowance, so
7292/// agent-stream free text (`Text`, `Thinking`, `title`, `prompt`,
7293/// `description`, `value_json`, `label`) and `SetSessionConfigOption`'s
7294/// `value_json` validate against the same rule the provider event stream
7295/// already applies to it.
7296fn contains_unsafe_control_bytes(value: &str) -> bool {
7297    value.chars().any(|character| {
7298        character.is_control() && !matches!(character, '\n' | '\r' | '\t')
7299    })
7300}
7301
7302/// Structural bound for an ACP-minted identifier (`mode_id`/`option_id`/
7303/// `model_id`/`tool_name`/catalog entry `id`/`name`/interaction option
7304/// `option_id`/`name`/`kind`) -- mirrors `gate4agent-node-protocol`'s own
7305/// `deserialize_acp_control_id`: non-empty, bounded by
7306/// `HARNESS_AGENT_STREAM_ID_MAX_BYTES`, free of every control character
7307/// (never just the unsafe subset `contains_unsafe_control_bytes` allows --
7308/// an id is not free text).
7309fn valid_agent_stream_id(value: &str) -> bool {
7310    !value.is_empty()
7311        && value.len() <= HARNESS_AGENT_STREAM_ID_MAX_BYTES
7312        && !value.chars().any(char::is_control)
7313}
7314
7315/// Structural bound for `ResolveInteraction`'s `correlation_id` and
7316/// `InteractionPrompt`'s own copy of it -- mirrors `gate4agent-node-
7317/// protocol`'s own `deserialize_acp_correlation_id`, bounded by
7318/// `HARNESS_OBSERVATION_LABEL_MAX_BYTES` (the same source constant,
7319/// `hatchery_observation_protocol::OBSERVATION_LABEL_MAX_BYTES`, that
7320/// bound already mirrors) since a correlation id is exactly what
7321/// `ObservationKindV1::ApprovalRequested`/`QuestionRequested` minted onto
7322/// the timeline at that bound.
7323fn valid_agent_stream_correlation_id(value: &str) -> bool {
7324    !value.is_empty()
7325        && value.len() <= HARNESS_OBSERVATION_LABEL_MAX_BYTES
7326        && !value.chars().any(char::is_control)
7327}
7328
7329fn validate_native_entries(
7330    route: &HarnessNativeSessionRouteV1,
7331    entries: &[HarnessNativeSessionCatalogEntryV1],
7332) -> Result<(), HarnessOperatorApiError> {
7333    if entries.len() > usize::from(HARNESS_NATIVE_SESSION_CATALOG_LIMIT_MAX) {
7334        return Err(HarnessOperatorApiError::InvalidNativeHistory);
7335    }
7336    for (index, entry) in entries.iter().enumerate() {
7337        entry.validate_for_route(route)?;
7338        if entries[..index].iter().any(|existing| {
7339            existing.selection_id == entry.selection_id
7340                || entry.record_id.is_some() && existing.record_id == entry.record_id
7341        }) {
7342            return Err(HarnessOperatorApiError::InvalidNativeHistory);
7343        }
7344    }
7345    Ok(())
7346}
7347
7348fn validate_operator_limit(limit: u16) -> Result<(), HarnessOperatorApiError> {
7349    if !(1..=HARNESS_ENTITY_PAGE_LIMIT_MAX).contains(&limit) {
7350        return Err(HarnessOperatorApiError::InvalidLimit);
7351    }
7352    Ok(())
7353}
7354
7355fn validate_operator_timeline_limit(limit: u16) -> Result<(), HarnessOperatorApiError> {
7356    if !(1..=HARNESS_TIMELINE_PAGE_LIMIT_MAX).contains(&limit) {
7357        return Err(HarnessOperatorApiError::InvalidLimit);
7358    }
7359    Ok(())
7360}
7361
7362fn validate_operator_terminal_limit(limit: u16) -> Result<(), HarnessOperatorApiError> {
7363    if !(1..=HARNESS_TERMINAL_PAGE_LIMIT_MAX).contains(&limit) {
7364        return Err(HarnessOperatorApiError::InvalidLimit);
7365    }
7366    Ok(())
7367}
7368
7369fn validate_limit(limit: u16, maximum: u16) -> Result<(), HarnessReadApiError> {
7370    if !(1..=maximum).contains(&limit) {
7371        return Err(HarnessReadApiError::InvalidLimit);
7372    }
7373    Ok(())
7374}
7375
7376fn validate_bounded<T>(
7377    values: &[T],
7378    maximum: u16,
7379    validate: impl Fn(&T) -> Result<(), HarnessReadApiError>,
7380) -> Result<(), HarnessReadApiError> {
7381    if values.len() > maximum as usize {
7382        return Err(HarnessReadApiError::InvalidCollection);
7383    }
7384    for value in values { validate(value)?; }
7385    Ok(())
7386}
7387
7388fn validate_bounded_ids_with_max<T: Ord>(
7389    values: &[T],
7390    maximum: usize,
7391) -> Result<(), HarnessReadApiError> {
7392    if values.len() > maximum
7393        || values.windows(2).any(|pair| pair[0] >= pair[1])
7394    {
7395        return Err(HarnessReadApiError::InvalidCollection);
7396    }
7397    Ok(())
7398}
7399
7400fn validate_monitor_facts<T>(values: &[T]) -> Result<(), HarnessReadApiError> {
7401    if values.len() > HARNESS_MONITOR_FACTS_MAX {
7402        return Err(HarnessReadApiError::InvalidCollection);
7403    }
7404    Ok(())
7405}
7406
7407fn validate_optional_observation_text(
7408    field: &'static str,
7409    value: Option<&str>,
7410    maximum: usize,
7411) -> Result<(), HarnessReadApiError> {
7412    let Some(value) = value else { return Ok(()); };
7413    if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
7414        return Err(HarnessReadApiError::InvalidText(field));
7415    }
7416    Ok(())
7417}
7418
7419fn validate_observation_correlation(value: Option<u16>) -> Result<(), HarnessReadApiError> {
7420    if value.is_some_and(|value| value == 0 || usize::from(value) > HARNESS_MONITOR_FACTS_MAX) {
7421        return Err(HarnessReadApiError::InvalidTimelineEntry);
7422    }
7423    Ok(())
7424}
7425
7426fn validate_observation_relative_path(path: &str) -> Result<(), HarnessReadApiError> {
7427    if path.is_empty()
7428        || path.len() > HARNESS_OBSERVATION_PATH_MAX_BYTES
7429        || path.starts_with('/')
7430        || path.contains('\\')
7431        || path.contains(':')
7432        || path.chars().any(char::is_control)
7433        || path.split('/').any(|component| {
7434            component.is_empty() || matches!(component, "." | "..")
7435        })
7436    {
7437        return Err(HarnessReadApiError::InvalidText("relative path"));
7438    }
7439    Ok(())
7440}
7441
7442fn require_observed_or_empty<T>(
7443    state: FeatureObservationStateV1,
7444    values: &[T],
7445) -> Result<(), HarnessReadApiError> {
7446    if state != FeatureObservationStateV1::Observed && !values.is_empty() {
7447        return Err(HarnessReadApiError::InvalidMonitorDetail);
7448    }
7449    Ok(())
7450}
7451
7452fn validate_feature_counts(monitor: &SessionMonitorV1) -> Result<(), HarnessReadApiError> {
7453    if monitor.features.todo != FeatureObservationStateV1::Observed
7454        && (monitor.todo_total != 0 || monitor.todo_completed != 0)
7455        || monitor.features.tools != FeatureObservationStateV1::Observed
7456            && monitor.active_tools != 0
7457        || monitor.features.subagents != FeatureObservationStateV1::Observed
7458            && monitor.active_subagents != 0
7459        || monitor.features.interactions != FeatureObservationStateV1::Observed
7460            && monitor.active_interactions != 0
7461        || monitor.features.owned_processes != FeatureObservationStateV1::Observed
7462            && monitor.active_processes != 0
7463        || monitor.features.usage != FeatureObservationStateV1::Observed
7464            && (monitor.input_tokens != 0
7465                || monitor.output_tokens != 0
7466                || monitor.cache_read_tokens != 0
7467                || monitor.cache_write_tokens != 0
7468                || monitor.reasoning_tokens != 0
7469                || monitor.context_window_tokens.is_some())
7470    {
7471        return Err(HarnessReadApiError::InvalidMonitorDetail);
7472    }
7473    if matches!(monitor.availability, ProjectionAvailabilityV1::Unknown | ProjectionAvailabilityV1::NotObserved)
7474        && [
7475            monitor.features.todo,
7476            monitor.features.tools,
7477            monitor.features.subagents,
7478            monitor.features.interactions,
7479            monitor.features.owned_processes,
7480            monitor.features.files,
7481            monitor.features.usage,
7482            monitor.features.history,
7483        ]
7484        .contains(&FeatureObservationStateV1::Observed)
7485    {
7486        return Err(HarnessReadApiError::InvalidMonitorDetail);
7487    }
7488    Ok(())
7489}
7490
7491fn validate_text(
7492    field: &'static str,
7493    value: &str,
7494    maximum: usize,
7495    allow_empty: bool,
7496) -> Result<(), HarnessReadApiError> {
7497    if value.len() > maximum || !allow_empty && value.trim().is_empty() || value.contains('\0') {
7498        return Err(HarnessReadApiError::InvalidText(field));
7499    }
7500    Ok(())
7501}
7502
7503fn validate_timestamps(created: u64, updated: u64) -> Result<(), HarnessReadApiError> {
7504    if created == 0 || updated < created {
7505        return Err(HarnessReadApiError::InvalidTimestamp);
7506    }
7507    Ok(())
7508}
7509
7510fn valid_transfer_timestamps<const N: usize>(
7511    created: u64,
7512    updated: u64,
7513    optional: [Option<u64>; N],
7514) -> bool {
7515    created != 0
7516        && updated >= created
7517        && optional.into_iter().flatten().all(|timestamp| {
7518            timestamp >= created && timestamp <= updated
7519        })
7520}
7521
7522#[cfg(test)]
7523mod tests {
7524    use super::*;
7525
7526    fn managed_run_correlation() -> HarnessRunCorrelationV1 {
7527        HarnessRunCorrelationV1 {
7528            run_id: HarnessRunId::new(format!("hrun_{}", "a".repeat(24))).unwrap(),
7529            run_revision: HarnessRevision::new(7).unwrap(),
7530            task_id: HarnessTaskId::new(format!("htask_{}", "b".repeat(24))).unwrap(),
7531            node_id: HarnessSelectorV1::new("node-a").unwrap(),
7532            node_incarnation_id: HarnessNodeIncarnationV1::new("07".repeat(16)).unwrap(),
7533            workspace_id: HarnessSelectorV1::new("workspace-a").unwrap(),
7534            provider_profile: HarnessSelectorV1::new("codex-default").unwrap(),
7535            mode: HarnessExecutionModeV1::Pty,
7536            worktree: HarnessRunWorktreeViewV1::Managed {
7537                worktree_ref: HarnessSelectorV1::new("worktree-a").unwrap(),
7538            },
7539            session: HarnessRunSessionViewV1::Managed(HarnessManagedRunSessionV1 {
7540                record_id: HarnessSelectorV1::new("record-a").unwrap(),
7541                active_session: Some(HarnessRuntimeIdentityV1 {
7542                    instance_id: 41,
7543                    generation: 3,
7544                }),
7545            }),
7546            availability: HarnessRunCorrelationAvailabilityV1::Available,
7547            observed_at_unix_ms: Some(100),
7548        }
7549    }
7550
7551    fn run_transfer_summary() -> HarnessRunTransferSummaryV1 {
7552        let run_id = HarnessRunId::new(format!("hrun_{}", "a".repeat(24))).unwrap();
7553        HarnessRunTransferSummaryV1 {
7554            run_id: run_id.clone(),
7555            run_revision: HarnessRevision::new(9).unwrap(),
7556            delivery: Some(HarnessRunDeliveryTransferV1 {
7557                delivery_ref: HarnessDeliveryRef::new(format!(
7558                    "hdelivery_{}",
7559                    "b".repeat(24),
7560                )).unwrap(),
7561                revision: HarnessRevision::new(3).unwrap(),
7562                state: HarnessDeliveryStateV1::Committed,
7563                selector: HarnessSelectorV1::new("delivery-safe").unwrap(),
7564                bundle_id: HarnessDeliveryBundleIdV1::new("bundle-safe").unwrap(),
7565                bundle_revision: HarnessDeliveryBundleRevisionV1::new("revision-4").unwrap(),
7566                bundle_digest: HarnessDeliveryBundleDigestV1::new(format!(
7567                    "sha256:{}",
7568                    "c".repeat(64),
7569                )).unwrap(),
7570                manifest_digest: HarnessDeliveryManifestDigestV2::new(format!(
7571                    "sha256:{}",
7572                    "d".repeat(64),
7573                )).unwrap(),
7574                receipt_ref: Some(HarnessReceiptRef::new(format!(
7575                    "hreceipt_{}",
7576                    "e".repeat(24),
7577                )).unwrap()),
7578                created_at_unix_ms: 10,
7579                updated_at_unix_ms: 30,
7580                staged_at_unix_ms: Some(20),
7581                committed_at_unix_ms: Some(30),
7582            }),
7583            continuation: Some(HarnessRunContinuationTransferV1 {
7584                continuation_ref: HarnessContinuationRef::new(format!(
7585                    "hcontinuation_{}",
7586                    "f".repeat(24),
7587                )).unwrap(),
7588                receipt_ref: HarnessReceiptRef::new(format!(
7589                    "hreceipt_{}",
7590                    "0".repeat(24),
7591                )).unwrap(),
7592                revision: HarnessRevision::new(4).unwrap(),
7593                state: HarnessContinuationStateV1::Bound,
7594                source_run_id: HarnessRunId::new(format!(
7595                    "hrun_{}",
7596                    "1".repeat(24),
7597                )).unwrap(),
7598                target_run_id: run_id,
7599                source_provider: HarnessSelectorV1::new("claude").unwrap(),
7600                context: Some(HarnessRunContextTransferV1 {
7601                    context_ref: HarnessSelectorV1::new("context-safe").unwrap(),
7602                    digest: format!("sha256:{}", "2".repeat(64)),
7603                    source_message_count: 12,
7604                    retained_message_count: 8,
7605                    byte_len: 4096,
7606                    truncated: true,
7607                }),
7608                prepared_at_unix_ms: 10,
7609                exporting_at_unix_ms: Some(15),
7610                exported_at_unix_ms: Some(20),
7611                bound_at_unix_ms: Some(25),
7612                expired_at_unix_ms: None,
7613                outcome_unknown_at_unix_ms: None,
7614                outcome_unknown_reason: None,
7615                created_at_unix_ms: 10,
7616                updated_at_unix_ms: 25,
7617            }),
7618        }
7619    }
7620
7621    fn run_workspace_origin() -> HarnessRunWorkspaceOriginV1 {
7622        HarnessRunWorkspaceOriginV1 {
7623            run_id: HarnessRunId::new(format!("hrun_{}", "a".repeat(24))).unwrap(),
7624            run_revision: HarnessRevision::new(7).unwrap(),
7625            node_id: HarnessSelectorV1::new("node-a").unwrap(),
7626            node_incarnation_id: HarnessNodeIncarnationV1::new("07".repeat(16)).unwrap(),
7627            workspace_id: HarnessSelectorV1::new("workspace-a").unwrap(),
7628        }
7629    }
7630
7631    fn node_workspace_origin() -> HarnessNodeWorkspaceOriginV1 {
7632        HarnessNodeWorkspaceOriginV1 {
7633            node_id: "node-a".to_owned(),
7634            node_incarnation_id: "07".repeat(16),
7635            workspace_id: "workspace-a".to_owned(),
7636        }
7637    }
7638
7639    fn reverse_attribution_workspace() -> HarnessReverseAttributionWorkspaceV1 {
7640        HarnessReverseAttributionWorkspaceV1 {
7641            node_id: HarnessSelectorV1::new("node-a").unwrap(),
7642            node_incarnation_id: HarnessNodeIncarnationV1::new("07".repeat(16)).unwrap(),
7643            workspace_id: HarnessSelectorV1::new("workspace-a").unwrap(),
7644        }
7645    }
7646
7647    fn reverse_attribution_link(run_byte: char) -> HarnessReverseAttributionLinkV1 {
7648        HarnessReverseAttributionLinkV1 {
7649            task_id: HarnessTaskId::new(format!("htask_{}", "1".repeat(24))).unwrap(),
7650            run_id: HarnessRunId::new(format!("hrun_{}", run_byte.to_string().repeat(24)))
7651                .unwrap(),
7652            run_revision: HarnessRevision::new(3).unwrap(),
7653            binding: HarnessReverseAttributionBindingV1::Workspace {
7654                workspace: reverse_attribution_workspace(),
7655            },
7656            relation: HarnessReverseAttributionRelationV1::WorkspaceScope,
7657        }
7658    }
7659
7660    fn git_commit(id_byte: char) -> HarnessGitCommitV1 {
7661        HarnessGitCommitV1 {
7662            id: HarnessGitObjectIdV1::new(id_byte.to_string().repeat(40)).unwrap(),
7663            parents: Vec::new(),
7664            subject: "Bounded subject".to_owned(),
7665            author_name: "Author".to_owned(),
7666            authored_at: "2026-08-16T10:00:00Z".to_owned(),
7667            committer_name: "Committer".to_owned(),
7668            committed_at: "2026-08-16T10:00:00Z".to_owned(),
7669            signature_status: HarnessGitSignatureStatusV1::NoSignature,
7670            signer: None,
7671        }
7672    }
7673
7674    fn assert_json_has_no_forbidden_keys(encoded: &str, forbidden: &[&str]) {
7675        fn visit(value: &serde_json::Value, forbidden: &[&str]) {
7676            match value {
7677                serde_json::Value::Object(fields) => {
7678                    for (field, value) in fields {
7679                        assert!(
7680                            !forbidden.iter().any(|forbidden| field == forbidden),
7681                            "response exposed structural field {field}",
7682                        );
7683                        visit(value, forbidden);
7684                    }
7685                }
7686                serde_json::Value::Array(values) => {
7687                    for value in values { visit(value, forbidden); }
7688                }
7689                _ => {}
7690            }
7691        }
7692
7693        let value: serde_json::Value = serde_json::from_str(encoded).unwrap();
7694        visit(&value, forbidden);
7695    }
7696
7697    #[test]
7698    fn credential_debug_and_errors_never_expose_secret() {
7699        let credential = HarnessReadCredential::parse(format!("g4ah2_aa.{}", "0".repeat(64)))
7700            .expect("credential");
7701        assert_eq!(format!("{credential:?}"), "HarnessReadCredential([REDACTED])");
7702        assert!(!HarnessReadApiError::MalformedCredential.to_string().contains(credential.expose()));
7703    }
7704
7705    #[test]
7706    fn operator_and_agent_credentials_are_strictly_separated_and_redacted() {
7707        let operator = HarnessOperatorCredential::parse(format!(
7708            "g4aho_{}",
7709            "a".repeat(64),
7710        )).expect("operator credential");
7711        let read = format!("g4ah2_aa.{}", "0".repeat(64));
7712        assert!(HarnessOperatorCredential::parse(&read).is_err());
7713        assert!(HarnessReadCredential::parse(operator.expose()).is_err());
7714        assert_eq!(
7715            format!("{operator:?}"),
7716            "HarnessOperatorCredential([REDACTED])",
7717        );
7718        assert!(!HarnessOperatorApiError::MalformedCredential
7719            .to_string().contains(operator.expose()));
7720    }
7721
7722    #[test]
7723    fn operator_frames_enforce_exact_cursor_and_collection_bounds() {
7724        assert!(HarnessOperatorRequestV1::TasksList {
7725            after_task_id: None,
7726            state: None,
7727            parent_task_id: None,
7728            limit: HARNESS_ENTITY_PAGE_LIMIT_MAX,
7729        }.validate().is_ok());
7730        assert!(HarnessOperatorRequestV1::TasksList {
7731            after_task_id: None,
7732            state: None,
7733            parent_task_id: None,
7734            limit: HARNESS_ENTITY_PAGE_LIMIT_MAX + 1,
7735        }.validate().is_err());
7736        let run_id = HarnessRunId::new(format!("hrun_{}", "a".repeat(24))).unwrap();
7737        assert!(HarnessOperatorRequestV1::TimelineRead {
7738            run_id,
7739            after_sequence: Some(0),
7740            limit: 1,
7741        }.validate().is_err());
7742    }
7743
7744    #[test]
7745    fn legacy_raw_schedule_variant_json_is_rejected() {
7746        let legacy = r#"{"kind":"schedule-ready-task","request":{}}"#;
7747        assert!(serde_json::from_str::<HarnessOperatorRequestV1>(legacy).is_err());
7748    }
7749
7750    #[test]
7751    fn operator_intent_is_authority_free() {
7752        let credential = HarnessOperatorCredential::parse(format!(
7753            "g4aho_{}",
7754            "a".repeat(64),
7755        )).unwrap();
7756        let request = HarnessOperatorRequestV1::SubmitIntent {
7757            intent: HarnessOperatorIntentV1 {
7758                request_ref: HarnessOperatorRequestRefV1::new(format!(
7759                    "hireq_{}",
7760                    "1".repeat(24),
7761                )).unwrap(),
7762                submitted_at_unix_ms: 10,
7763                action: HarnessOperatorActionV1::CreateTask {
7764                    title: "Harness-owned identity".to_owned(),
7765                    body: "Typed user intent".to_owned(),
7766                    parent_task_id: None,
7767                    dependencies: Vec::new(),
7768                    initial_state: HarnessTaskStateV1::Backlog,
7769                },
7770            },
7771        };
7772        let encoded_request = serde_json::to_string(&request).unwrap();
7773        assert!(!encoded_request.contains("authority"));
7774        assert!(!encoded_request.contains("operation_id"));
7775        assert!(!encoded_request.contains("idempotency_ref"));
7776        assert!(!encoded_request.contains("\"task_id\":"));
7777        let envelope = HarnessOperatorEnvelopeV1 {
7778            build_stamp: BUILD_STAMP.to_string(),
7779            credential,
7780            request,
7781        };
7782        assert!(envelope.validate().is_ok());
7783        let decoded: HarnessOperatorEnvelopeV1 = serde_json::from_slice(
7784            &serde_json::to_vec(&envelope).unwrap(),
7785        ).unwrap();
7786        assert_eq!(decoded, envelope);
7787    }
7788
7789    #[test]
7790    fn operator_run_correlation_is_exact() {
7791        let credential = HarnessOperatorCredential::parse(format!(
7792            "g4aho_{}",
7793            "a".repeat(64),
7794        )).unwrap();
7795        let request = HarnessOperatorRequestV1::RunCorrelationGet {
7796            run_id: HarnessRunId::new(format!("hrun_{}", "c".repeat(24))).unwrap(),
7797        };
7798        let envelope = HarnessOperatorEnvelopeV1 {
7799            build_stamp: BUILD_STAMP.to_string(),
7800            credential,
7801            request,
7802        };
7803        envelope.validate().unwrap();
7804        let decoded: HarnessOperatorEnvelopeV1 = serde_json::from_slice(
7805            &serde_json::to_vec(&envelope).unwrap(),
7806        ).unwrap();
7807        assert_eq!(decoded, envelope);
7808    }
7809
7810    #[test]
7811    fn operator_run_transfer_is_exact_private() {
7812        let credential = HarnessOperatorCredential::parse(format!(
7813            "g4aho_{}",
7814            "a".repeat(64),
7815        )).unwrap();
7816        let summary = run_transfer_summary();
7817        summary.validate().unwrap();
7818        let request = HarnessOperatorRequestV1::RunTransferGet {
7819            run_id: summary.run_id.clone(),
7820        };
7821        HarnessOperatorEnvelopeV1 {
7822            build_stamp: BUILD_STAMP.to_string(),
7823            credential,
7824            request,
7825        }.validate().unwrap();
7826
7827        let response = HarnessOperatorResponseV1::RunTransfer(summary.clone());
7828        response.validate().unwrap();
7829        let encoded = serde_json::to_string(&response).unwrap();
7830        let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
7831        assert_eq!(decoded, response);
7832        for forbidden in [
7833            "provider_home",
7834            "provider_native",
7835            "session_id",
7836            "source_root",
7837            "component_path",
7838            "component_name",
7839            "blob_bytes",
7840            "prompt",
7841            "node_store",
7842            "credential",
7843            "auth",
7844            "C:\\\\",
7845        ] {
7846            assert!(!encoded.contains(forbidden), "response exposed {forbidden}");
7847        }
7848
7849        let mut wrong_target = summary.clone();
7850        wrong_target.continuation.as_mut().unwrap().target_run_id =
7851            HarnessRunId::new(format!("hrun_{}", "9".repeat(24))).unwrap();
7852        assert!(matches!(
7853            wrong_target.validate(),
7854            Err(HarnessOperatorApiError::InvalidRunTransfer),
7855        ));
7856
7857        let mut invalid_delivery = summary.clone();
7858        invalid_delivery.delivery.as_mut().unwrap().committed_at_unix_ms = None;
7859        assert!(matches!(
7860            invalid_delivery.validate(),
7861            Err(HarnessOperatorApiError::InvalidRunTransfer),
7862        ));
7863
7864        let mut invalid_context = summary;
7865        invalid_context.continuation.as_mut().unwrap()
7866            .context.as_mut().unwrap().truncated = false;
7867        assert!(matches!(
7868            invalid_context.validate(),
7869            Err(HarnessOperatorApiError::InvalidRunTransfer),
7870        ));
7871    }
7872
7873    fn ordinary_launch_plan(plan_id: &str, digest: char) -> HarnessLaunchPlanSummaryV1 {
7874        HarnessLaunchPlanSummaryV1 {
7875            scheduled_launch: HarnessScheduledLaunchRefV2 {
7876                plan: HarnessLaunchPlanRefV1 {
7877                    plan_id: HarnessSelectorV1::new(plan_id).unwrap(),
7878                    revision: HarnessRevision::new(2).unwrap(),
7879                    digest: HarnessRequestDigest::new(digest.to_string().repeat(64)).unwrap(),
7880                },
7881                authority: HarnessLaunchAuthorityRefV1::OrdinaryOperator,
7882            },
7883            node_id: HarnessSelectorV1::new("node-a").unwrap(),
7884            workspace_id: HarnessSelectorV1::new("workspace-a").unwrap(),
7885            worktree: HarnessWorktreeIntentV1::Managed {
7886                worktree_ref: HarnessSelectorV1::new("worktree-a").unwrap(),
7887            },
7888            provider_profile: HarnessSelectorV1::new("codex-default").unwrap(),
7889            provider_id: HarnessSelectorV1::new("codex").unwrap(),
7890            mode: HarnessExecutionModeV1::Pty,
7891        }
7892    }
7893
7894    fn task_launch_options() -> HarnessTaskLaunchOptionsV1 {
7895        let task_id = HarnessTaskId::new(format!("htask_{}", "b".repeat(24))).unwrap();
7896        let legacy_plan = ordinary_launch_plan("plan-a", 'a');
7897        let plan = HarnessOrdinaryLaunchPlanOptionV1 {
7898            plan: legacy_plan.scheduled_launch.plan,
7899            node_id: legacy_plan.node_id,
7900            source_workspace_id: legacy_plan.workspace_id,
7901            provider_profile: legacy_plan.provider_profile,
7902            provider_id: legacy_plan.provider_id,
7903            mode: legacy_plan.mode,
7904        };
7905        let context = HarnessContextSourceSelectionV1 {
7906            source_run_id: HarnessRunId::new(format!("hrun_{}", "c".repeat(24))).unwrap(),
7907            source_run_revision: HarnessRevision::new(7).unwrap(),
7908            observed_at_unix_ms: 90,
7909            metadata_digest: HarnessRequestDigest::new("d".repeat(64)).unwrap(),
7910            node_id: HarnessSelectorV1::new("node-a").unwrap(),
7911            node_incarnation: HarnessSelectorV1::new("07".repeat(16)).unwrap(),
7912            workspace_id: HarnessSelectorV1::new("source-workspace").unwrap(),
7913            session_record_id: HarnessSelectorV1::new("record-a").unwrap(),
7914            active_session: Some(HarnessRuntimeIdentityV1 { instance_id: 41, generation: 3 }),
7915            message_count: 12,
7916            message_count_exact: true,
7917            completed_turn_count: Some(5),
7918            total_tokens: Some(4096),
7919            availability: HarnessContextSourceAvailabilityV1::Live,
7920            context_pack: None,
7921        };
7922        let delivery = HarnessDeliveryBundleSelectionV1 {
7923            bundle: HarnessDeliveryBundleV1 {
7924                selector: HarnessSelectorV1::new("review-kit").unwrap(),
7925                bundle_id: HarnessDeliveryBundleIdV1::new("bundle.review-kit").unwrap(),
7926                revision: HarnessDeliveryBundleRevisionV1::new("revision-7").unwrap(),
7927                digest: HarnessDeliveryBundleDigestV1::new(format!(
7928                    "sha256:{}",
7929                    "e".repeat(64),
7930                )).unwrap(),
7931                manifest_digest: HarnessDeliveryManifestDigestV2::new(format!(
7932                    "sha256:{}",
7933                    "f".repeat(64),
7934                )).unwrap(),
7935            },
7936            component_counts: vec![HarnessDeliveryComponentCountV1 {
7937                kind: HarnessDeliveryComponentKindV1::Skill,
7938                workspace_count: 2,
7939                session_count: 1,
7940            }],
7941        };
7942        let issuance = HarnessTaskLaunchIssuanceRefV1 {
7943            issuance_id: HarnessTaskLaunchIssuanceId::new(format!(
7944                "hissue_{}",
7945                "1".repeat(24),
7946            )).unwrap(),
7947            revision: HarnessRevision::new(2).unwrap(),
7948            digest: HarnessRequestDigest::new("2".repeat(64)).unwrap(),
7949        };
7950        HarnessTaskLaunchOptionsV1 {
7951            task_id: task_id.clone(),
7952            task_revision: HarnessRevision::new(5).unwrap(),
7953            policy_digest: HarnessRequestDigest::new("3".repeat(64)).unwrap(),
7954            plans: vec![plan],
7955            managed_worktree_profiles: vec![HarnessManagedWorktreeProfileOptionV1 {
7956                node_id: HarnessSelectorV1::new("node-a").unwrap(),
7957                node_incarnation: HarnessSelectorV1::new("07".repeat(16)).unwrap(),
7958                source_workspace_id: HarnessSelectorV1::new("workspace-a").unwrap(),
7959                profile_id: HarnessSelectorV1::new("review").unwrap(),
7960                profile_revision: HarnessSelectorV1::new("review.r7").unwrap(),
7961                retention: HarnessManagedWorktreeRetentionV1::RemoveWhenReleased,
7962                observed_at_unix_ms: 90,
7963            }],
7964            context_sources: vec![context],
7965            delivery_bundles: vec![delivery],
7966            current_issued_spec: Some(HarnessIssuedExecutionSpecSummaryV1 {
7967                task_id,
7968                execution_spec_id: HarnessExecutionSpecId::new(format!(
7969                    "hespec_{}",
7970                    "4".repeat(24),
7971                )).unwrap(),
7972                revision: HarnessRevision::new(2).unwrap(),
7973                launch_issuance: issuance,
7974                review_policy: HarnessTaskReviewPolicyV1::OperatorReview,
7975                created_at_unix_ms: 80,
7976                updated_at_unix_ms: 90,
7977            }),
7978            truncated: false,
7979            next_after: None,
7980            context_source_exclusions: vec![ContextSourceExclusionEntryV1 {
7981                run_id: HarnessRunId::new(format!("hrun_{}", "9".repeat(24))).unwrap(),
7982                exclusion: ContextSourceExclusionV1::NodeIncarnationUnknown {
7983                    node_id: HarnessSelectorV1::new("node-a").unwrap(),
7984                    node_incarnation: HarnessSelectorV1::new("07".repeat(16)).unwrap(),
7985                    known_incarnations: vec![HarnessSelectorV1::new("08".repeat(16)).unwrap()],
7986                },
7987            }],
7988        }
7989    }
7990
7991    #[test]
7992    fn operator_task_launch_contract_is_exact_and_private() {
7993        let credential = HarnessOperatorCredential::parse(format!(
7994            "g4aho_{}",
7995            "a".repeat(64),
7996        )).unwrap();
7997        let options = task_launch_options();
7998        options.validate().unwrap();
7999        let task_id = options.task_id.clone();
8000        let authority = HarnessOperatorAuthorityV1 {
8001            operation_id: HarnessOperationId::new(format!("hop_{}", "5".repeat(24))).unwrap(),
8002            idempotency_ref: HarnessIdempotencyRef::new(format!(
8003                "hidem_{}",
8004                "6".repeat(24),
8005            )).unwrap(),
8006            actor_id: HarnessSelectorV1::new("operator").unwrap(),
8007            now_unix_ms: 100,
8008        };
8009        let selection = HarnessReviewedTaskLaunchSelectionV1 {
8010            plan: options.plans[0].clone(),
8011            worktree: HarnessReviewedWorktreeSelectionV1::Managed {
8012                profile: options.managed_worktree_profiles[0].clone(),
8013            },
8014            context_source: Some(options.context_sources[0].clone()),
8015            delivery: Some(options.delivery_bundles[0].clone()),
8016            review_policy: HarnessTaskReviewPolicyV1::OperatorReview,
8017        };
8018        let replace = HarnessOperatorRequestV1::ReplaceTaskExecutionSpecV2 {
8019            request: HarnessReplaceTaskExecutionSpecRequestV2 {
8020                authority: authority.clone(),
8021                task_id: task_id.clone(),
8022                expected_task_revision: options.task_revision,
8023                expected_execution_spec_revision:
8024                    HarnessExpectedExecutionSpecRevisionV1::Exact(HarnessRevision::new(2).unwrap()),
8025                selection: selection.clone(),
8026            },
8027        };
8028        let issuance = options.current_issued_spec.as_ref().unwrap().launch_issuance.clone();
8029        let start = HarnessOperatorRequestV1::StartTaskV2 {
8030            request: HarnessStartTaskRequestV2 {
8031                authority: authority.clone(),
8032                task_id: task_id.clone(),
8033                expected_task_revision: options.task_revision,
8034                expected_execution_spec_revision: HarnessRevision::new(2).unwrap(),
8035                expected_launch_issuance: issuance,
8036            },
8037        };
8038        for request in [
8039            HarnessOperatorRequestV1::TaskLaunchOptionsGet {
8040                task_id: task_id.clone(),
8041                provider: None,
8042                workspace: None,
8043                plan_id: None,
8044                after: None,
8045            },
8046            replace.clone(),
8047            start.clone(),
8048        ] {
8049            request.validate().unwrap();
8050        }
8051        HarnessOperatorEnvelopeV1 {
8052            build_stamp: BUILD_STAMP.to_string(),
8053            credential,
8054            request: replace.clone(),
8055        }.validate().unwrap();
8056
8057        let replace_json = serde_json::to_string(&replace).unwrap();
8058        for forbidden in [
8059            "issuance_id",
8060            "policy_digest",
8061            "provider_home",
8062            "credential",
8063            "canonical_root",
8064            "display_root",
8065            "prompt",
8066        ] {
8067            assert!(!replace_json.contains(forbidden), "leaked {forbidden}");
8068        }
8069        let mut unknown = serde_json::to_value(start).unwrap();
8070        unknown["request"]["caller_issuance_digest"] = serde_json::json!("untrusted");
8071        assert!(serde_json::from_value::<HarnessOperatorRequestV1>(unknown).is_err());
8072
8073        let intent = HarnessOperatorIntentV1 {
8074            request_ref: HarnessOperatorRequestRefV1::new(format!(
8075                "hireq_{}",
8076                "7".repeat(24),
8077            )).unwrap(),
8078            submitted_at_unix_ms: 100,
8079            action: HarnessOperatorActionV1::ReplaceTaskExecutionSpecV2 {
8080                task_id,
8081                expected_task_revision: options.task_revision,
8082                expected_execution_spec_revision:
8083                    HarnessExpectedExecutionSpecRevisionV1::Exact(HarnessRevision::new(2).unwrap()),
8084                selection,
8085            },
8086        };
8087        let intent_json = serde_json::to_string(&intent).unwrap();
8088        assert!(!intent_json.contains("authority"));
8089        let start_intent = HarnessOperatorIntentV1 {
8090            request_ref: HarnessOperatorRequestRefV1::new(format!(
8091                "hireq_{}",
8092                "8".repeat(24),
8093            )).unwrap(),
8094            submitted_at_unix_ms: 101,
8095            action: HarnessOperatorActionV1::StartTaskV2 {
8096                task_id: options.task_id.clone(),
8097                expected_task_revision: options.task_revision,
8098                expected_execution_spec_revision: HarnessRevision::new(2).unwrap(),
8099                expected_launch_issuance: options.current_issued_spec.as_ref().unwrap()
8100                    .launch_issuance.clone(),
8101            },
8102        };
8103        let start_intent_json = serde_json::to_string(&start_intent).unwrap();
8104        assert!(!start_intent_json.contains("authority"));
8105    }
8106
8107    #[test]
8108    fn task_launch_options_are_bounded_canonical_correlated_and_redacted() {
8109        let options = task_launch_options();
8110        options.validate_for(&options.task_id).unwrap();
8111        let encoded = serde_json::to_string(&HarnessOperatorResponseV1::TaskLaunchOptions(
8112            options.clone(),
8113        )).unwrap();
8114        for forbidden in [
8115            "canonical_root",
8116            "display_root",
8117            "provider_session_id",
8118            "provider_home",
8119            "credential",
8120            "auth_token",
8121            "source_path",
8122            "payload",
8123        ] {
8124            assert!(!encoded.contains(forbidden), "leaked {forbidden}");
8125        }
8126
8127        let mut duplicate = options.clone();
8128        duplicate.plans.push(duplicate.plans[0].clone());
8129        assert!(matches!(
8130            duplicate.validate(),
8131            Err(HarnessOperatorApiError::InvalidTaskLaunchOptions),
8132        ));
8133        let mut oversized = options.clone();
8134        oversized.context_sources = vec![
8135            options.context_sources[0].clone();
8136            HARNESS_TASK_LAUNCH_OPTIONS_MAX + 1
8137        ];
8138        assert!(matches!(
8139            oversized.validate(),
8140            Err(HarnessOperatorApiError::InvalidTaskLaunchOptions),
8141        ));
8142        let other_task = HarnessTaskId::new(format!("htask_{}", "8".repeat(24))).unwrap();
8143        assert!(matches!(
8144            options.validate_for(&other_task),
8145            Err(HarnessOperatorApiError::InvalidTaskLaunchOptions),
8146        ));
8147    }
8148
8149    /// The launch-catalogue ceiling fix's read-side pagination: `provider`/
8150    /// `workspace`/`plan_id`/`after` on the request and `next_after` on the
8151    /// response are additive over the pre-pagination wire shape, so an older
8152    /// peer's JSON (never carrying any of them) must still decode.
8153    #[test]
8154    fn task_launch_options_paging_fields_are_additive_and_next_after_is_checked() {
8155        let task_id = HarnessTaskId::new(format!("htask_{}", "9".repeat(24))).unwrap();
8156        let legacy_request_json = serde_json::json!({
8157            "kind": "task-launch-options-get",
8158            "task_id": task_id.as_str(),
8159        });
8160        let decoded: HarnessOperatorRequestV1 =
8161            serde_json::from_value(legacy_request_json).unwrap();
8162        assert!(matches!(
8163            decoded,
8164            HarnessOperatorRequestV1::TaskLaunchOptionsGet {
8165                provider: None, workspace: None, plan_id: None, after: None, ..
8166            },
8167        ));
8168
8169        let mut options = task_launch_options();
8170        let legacy_response_json = serde_json::to_value(&options).unwrap();
8171        assert!(legacy_response_json.get("next_after").is_some());
8172        let mut without_next_after = legacy_response_json.clone();
8173        without_next_after.as_object_mut().unwrap().remove("next_after");
8174        let redecoded: HarnessTaskLaunchOptionsV1 =
8175            serde_json::from_value(without_next_after).unwrap();
8176        assert_eq!(redecoded.next_after, None);
8177        redecoded.validate().unwrap();
8178
8179        let plan_id = options.plans[0].plan.plan_id.clone();
8180        options.next_after = Some(plan_id.clone());
8181        assert!(matches!(
8182            options.validate(),
8183            Err(HarnessOperatorApiError::InvalidTaskLaunchOptions),
8184        ), "next_after without truncated must be refused");
8185        options.truncated = true;
8186        options.validate().unwrap();
8187        options.next_after = Some(HarnessSelectorV1::new("not-the-last-plan").unwrap());
8188        assert!(matches!(
8189            options.validate(),
8190            Err(HarnessOperatorApiError::InvalidTaskLaunchOptions),
8191        ), "next_after must name the last plan on the page");
8192    }
8193
8194    /// `context_source_exclusions` is additive over the pre-instrumentation
8195    /// wire shape (same discipline as `next_after` above), round-trips
8196    /// exactly, and is bounded/ordered independently of the other four
8197    /// lists.
8198    #[test]
8199    fn context_source_exclusions_are_additive_bounded_and_ordered() {
8200        let options = task_launch_options();
8201        options.validate_for(&options.task_id).unwrap();
8202        assert_eq!(options.context_source_exclusions.len(), 1);
8203
8204        let mut legacy_json = serde_json::to_value(&options).unwrap();
8205        legacy_json.as_object_mut().unwrap().remove("context_source_exclusions");
8206        let redecoded: HarnessTaskLaunchOptionsV1 = serde_json::from_value(legacy_json).unwrap();
8207        assert!(redecoded.context_source_exclusions.is_empty());
8208        redecoded.validate().unwrap();
8209
8210        let entry = options.context_source_exclusions[0].clone();
8211        let encoded = serde_json::to_string(&entry).unwrap();
8212        assert!(encoded.contains("\"reason\":\"node-incarnation-unknown\""));
8213        let redecoded_entry: ContextSourceExclusionEntryV1 = serde_json::from_str(&encoded).unwrap();
8214        assert_eq!(redecoded_entry, entry);
8215
8216        let mut oversized = options.clone();
8217        oversized.context_source_exclusions =
8218            vec![entry.clone(); HARNESS_CONTEXT_SOURCE_EXCLUSIONS_MAX + 1];
8219        assert!(matches!(
8220            oversized.validate(),
8221            Err(HarnessOperatorApiError::InvalidTaskLaunchOptions),
8222        ));
8223
8224        let mut duplicated = options.clone();
8225        duplicated.context_source_exclusions.push(entry);
8226        assert!(matches!(
8227            duplicated.validate(),
8228            Err(HarnessOperatorApiError::InvalidTaskLaunchOptions),
8229        ), "context_source_exclusions must stay sorted and duplicate-free by run_id");
8230    }
8231
8232    /// Every [`ContextSourceExclusionV1`] variant round-trips through JSON
8233    /// exactly and validates -- covers every field-carrying shape the
8234    /// enum's `#[serde(tag = "reason")]` produces, not just the one sample
8235    /// `task_launch_options()` carries above.
8236    #[test]
8237    fn context_source_exclusion_variants_round_trip_every_shape() {
8238        let samples = [
8239            ContextSourceExclusionV1::LifecycleNotLive {
8240                lifecycle: HarnessRunLifecycleV1::Cancelled,
8241            },
8242            ContextSourceExclusionV1::NoBinding,
8243            ContextSourceExclusionV1::NoActiveManagedSession,
8244            ContextSourceExclusionV1::NodeIncarnationUnknown {
8245                node_id: HarnessSelectorV1::new("node-a").unwrap(),
8246                node_incarnation: HarnessSelectorV1::new("07".repeat(16)).unwrap(),
8247                known_incarnations: vec![HarnessSelectorV1::new("08".repeat(16)).unwrap()],
8248            },
8249            ContextSourceExclusionV1::ManagedSessionRecordMismatch {
8250                record_id: HarnessSelectorV1::new("record-a").unwrap(),
8251                workspace_id: HarnessSelectorV1::new("workspace-a").unwrap(),
8252                instance_id: 7,
8253                generation: 3,
8254                node_has_records: 4,
8255            },
8256            ContextSourceExclusionV1::ManagedSessionsPageTruncated {
8257                record_id: HarnessSelectorV1::new("record-a").unwrap(),
8258                page_len: 128,
8259                total: 185,
8260            },
8261            ContextSourceExclusionV1::MonitorUnavailable,
8262            ContextSourceExclusionV1::ProjectionNotLive {
8263                availability: ProjectionAvailabilityV1::Partial,
8264                freshness: ProjectionFreshnessV1::Stale,
8265                transport_incomplete: true,
8266            },
8267            ContextSourceExclusionV1::DurableNodeUnknown {
8268                node_id: HarnessSelectorV1::new("node-a").unwrap(),
8269            },
8270        ];
8271        for exclusion in samples {
8272            exclusion.validate().unwrap();
8273            let encoded = serde_json::to_string(&exclusion).unwrap();
8274            let decoded: ContextSourceExclusionV1 = serde_json::from_str(&encoded).unwrap();
8275            assert_eq!(decoded, exclusion);
8276        }
8277
8278        let oversized = ContextSourceExclusionV1::NodeIncarnationUnknown {
8279            node_id: HarnessSelectorV1::new("node-a").unwrap(),
8280            node_incarnation: HarnessSelectorV1::new("07".repeat(16)).unwrap(),
8281            known_incarnations: vec![
8282                HarnessSelectorV1::new("08".repeat(16)).unwrap();
8283                CONTEXT_SOURCE_EXCLUSION_KNOWN_INCARNATIONS_MAX + 1
8284            ],
8285        };
8286        assert!(matches!(
8287            oversized.validate(),
8288            Err(HarnessOperatorApiError::InvalidTaskLaunchOptions),
8289        ));
8290    }
8291
8292    #[test]
8293    fn operator_execution_requests_are_exact() {
8294        let credential = HarnessOperatorCredential::parse(format!(
8295            "g4aho_{}",
8296            "a".repeat(64),
8297        )).unwrap();
8298        let authority = HarnessOperatorAuthorityV1 {
8299            operation_id: HarnessOperationId::new(format!("hop_{}", "4".repeat(24))).unwrap(),
8300            idempotency_ref: HarnessIdempotencyRef::new(format!(
8301                "hidem_{}",
8302                "5".repeat(24),
8303            )).unwrap(),
8304            actor_id: HarnessSelectorV1::new("operator").unwrap(),
8305            now_unix_ms: 40,
8306        };
8307        let task_id = HarnessTaskId::new(format!("htask_{}", "b".repeat(24))).unwrap();
8308        let requests = [
8309            HarnessOperatorRequestV1::LaunchPlansList {
8310                after_plan_id: Some(HarnessSelectorV1::new("plan-a").unwrap()),
8311                limit: HARNESS_LAUNCH_PLAN_PAGE_LIMIT_MAX,
8312            },
8313            HarnessOperatorRequestV1::TaskExecutionSpecGet {
8314                task_id: task_id.clone(),
8315            },
8316            HarnessOperatorRequestV1::ReplaceTaskExecutionSpec {
8317                request: HarnessReplaceTaskExecutionSpecRequestV1 {
8318                    authority: authority.clone(),
8319                    task_id: task_id.clone(),
8320                    expected_task_revision: HarnessRevision::new(3).unwrap(),
8321                    expected_execution_spec_revision:
8322                        HarnessExpectedExecutionSpecRevisionV1::Absent,
8323                    spec: HarnessTaskExecutionSpecInputV1 {
8324                        scheduled_launch: ordinary_launch_plan("plan-a", 'c').scheduled_launch,
8325                        review_policy: HarnessTaskReviewPolicyV1::OperatorReview,
8326                    },
8327                },
8328            },
8329            HarnessOperatorRequestV1::StartTask {
8330                request: HarnessStartTaskRequestV1 {
8331                    authority,
8332                    task_id,
8333                    expected_task_revision: HarnessRevision::new(3).unwrap(),
8334                    expected_execution_spec_revision: HarnessRevision::new(1).unwrap(),
8335                    expected_scheduled_launch_digest: HarnessRequestDigest::new(
8336                        "d".repeat(64),
8337                    ).unwrap(),
8338                },
8339            },
8340        ];
8341        for request in requests {
8342            assert!(request.validate().is_ok());
8343            assert!(HarnessOperatorEnvelopeV1 {
8344                build_stamp: BUILD_STAMP.to_string(),
8345                credential: credential.clone(),
8346                request,
8347            }.validate().is_ok());
8348        }
8349
8350        assert!(matches!(
8351            HarnessOperatorRequestV1::LaunchPlansList {
8352                after_plan_id: None,
8353                limit: HARNESS_LAUNCH_PLAN_PAGE_LIMIT_MAX + 1,
8354            }.validate(),
8355            Err(HarnessOperatorApiError::InvalidLimit),
8356        ));
8357    }
8358
8359    #[test]
8360    fn launch_plan_page_is_bounded_canonical_and_redacted() {
8361        let first = ordinary_launch_plan("plan-a", 'a');
8362        let second = ordinary_launch_plan("plan-b", 'b');
8363        let page = HarnessLaunchPlanPageV1 {
8364            plans: vec![first.clone(), second.clone()],
8365            next_plan_id: Some(second.scheduled_launch.plan.plan_id.clone()),
8366        };
8367        page.validate().unwrap();
8368        let encoded = serde_json::to_string(&page).unwrap();
8369        for sentinel in [
8370            "delivery",
8371            "continuation",
8372            "harness_mcp",
8373            "spawn_spec",
8374            "environment",
8375            "provider_home",
8376            "credential",
8377            "raw_path",
8378        ] {
8379            assert!(!encoded.contains(sentinel));
8380        }
8381        assert_eq!(serde_json::from_str::<HarnessLaunchPlanPageV1>(&encoded).unwrap(), page);
8382
8383        assert!(matches!(
8384            HarnessLaunchPlanPageV1 {
8385                plans: vec![second, first],
8386                next_plan_id: None,
8387            }.validate(),
8388            Err(HarnessOperatorApiError::InvalidLaunchPlans),
8389        ));
8390    }
8391
8392    #[test]
8393    fn execution_spec_and_start_intents_are_authority_free() {
8394        let intent = HarnessOperatorIntentV1 {
8395            request_ref: HarnessOperatorRequestRefV1::new(format!(
8396                "hireq_{}",
8397                "1".repeat(24),
8398            )).unwrap(),
8399            submitted_at_unix_ms: 50,
8400            action: HarnessOperatorActionV1::StartTask {
8401                task_id: HarnessTaskId::new(format!("htask_{}", "2".repeat(24))).unwrap(),
8402                expected_task_revision: HarnessRevision::new(7).unwrap(),
8403                expected_execution_spec_revision: HarnessRevision::new(3).unwrap(),
8404                expected_scheduled_launch_digest: HarnessRequestDigest::new(
8405                    "c".repeat(64),
8406                ).unwrap(),
8407            },
8408        };
8409        intent.validate().unwrap();
8410        let request = HarnessOperatorRequestV1::SubmitIntent { intent };
8411        let encoded = serde_json::to_string(&request).unwrap();
8412        assert!(!encoded.contains("authority"));
8413        assert!(!encoded.contains("operation_id"));
8414        assert!(!encoded.contains("idempotency_ref"));
8415
8416        let replace = HarnessOperatorRequestV1::SubmitIntent {
8417            intent: HarnessOperatorIntentV1 {
8418                request_ref: HarnessOperatorRequestRefV1::new(format!(
8419                    "hireq_{}",
8420                    "3".repeat(24),
8421                )).unwrap(),
8422                submitted_at_unix_ms: 51,
8423                action: HarnessOperatorActionV1::ReplaceTaskExecutionSpec {
8424                    task_id: HarnessTaskId::new(format!(
8425                        "htask_{}",
8426                        "4".repeat(24),
8427                    )).unwrap(),
8428                    expected_task_revision: HarnessRevision::new(2).unwrap(),
8429                    expected_execution_spec_revision:
8430                        HarnessExpectedExecutionSpecRevisionV1::Absent,
8431                    spec: HarnessTaskExecutionSpecInputV1 {
8432                        scheduled_launch: ordinary_launch_plan("plan-a", 'd').scheduled_launch,
8433                        review_policy: HarnessTaskReviewPolicyV1::OperatorReview,
8434                    },
8435                },
8436            },
8437        };
8438        replace.validate().unwrap();
8439    }
8440
8441    #[test]
8442    fn run_correlation_round_trip_preserves_atomic_identity_and_redacts() {
8443        let correlation = managed_run_correlation();
8444        correlation.validate().unwrap();
8445        let response = HarnessOperatorResponseV1::RunCorrelation(correlation.clone());
8446        response.validate().unwrap();
8447        let encoded = serde_json::to_vec(&response).unwrap();
8448        let decoded: HarnessOperatorResponseV1 = serde_json::from_slice(&encoded).unwrap();
8449        assert_eq!(decoded, response);
8450        let text = String::from_utf8(encoded.clone()).unwrap();
8451        for forbidden in [
8452            "g4aho_",
8453            "credential",
8454            "provider_identity",
8455            "session_id",
8456            "spawn_spec",
8457            "environment",
8458            "C:\\\\",
8459        ] {
8460            assert!(!text.contains(forbidden));
8461        }
8462        assert!(text.contains("provider_profile"));
8463        assert!(text.contains("node_incarnation_id"));
8464        assert!(text.contains("active_session"));
8465
8466        let mut without_generation: serde_json::Value =
8467            serde_json::from_slice(&encoded).unwrap();
8468        without_generation["value"]["session"]["value"]["active_session"]
8469            .as_object_mut().unwrap().remove("generation");
8470        assert!(serde_json::from_value::<HarnessOperatorResponseV1>(
8471            without_generation,
8472        ).is_err());
8473    }
8474
8475    #[test]
8476    fn run_correlation_rejects_inconsistent_availability_and_unbounded_fields() {
8477        let mut correlation = managed_run_correlation();
8478        correlation.availability = HarnessRunCorrelationAvailabilityV1::NotObserved;
8479        assert!(matches!(
8480            correlation.validate(),
8481            Err(HarnessOperatorApiError::InvalidRunCorrelation),
8482        ));
8483        correlation.observed_at_unix_ms = None;
8484        correlation.validate().unwrap();
8485
8486        correlation.availability = HarnessRunCorrelationAvailabilityV1::Dormant;
8487        correlation.observed_at_unix_ms = Some(101);
8488        assert!(matches!(
8489            correlation.validate(),
8490            Err(HarnessOperatorApiError::InvalidRunCorrelation),
8491        ));
8492
8493        let unknown = r#"{
8494            "run_id":"hrun_aaaaaaaaaaaaaaaaaaaaaaaa",
8495            "run_revision":1,
8496            "task_id":"htask_bbbbbbbbbbbbbbbbbbbbbbbb",
8497            "node_id":"node-a",
8498            "node_incarnation_id":"07070707070707070707070707070707",
8499            "workspace_id":"workspace-a",
8500            "provider_profile":"codex-default",
8501            "mode":"inline",
8502            "worktree":{"kind":"existing"},
8503            "session":{"kind":"inline","value":{"inline_ref":"hinline_cccccccccccccccccccccccc"}},
8504            "availability":"not-observed",
8505            "observed_at_unix_ms":null,
8506            "raw_path":"C:\\\\private"
8507        }"#;
8508        assert!(serde_json::from_str::<HarnessRunCorrelationV1>(unknown).is_err());
8509        assert!(HarnessNodeIncarnationV1::new("x".repeat(129)).is_err());
8510        assert!(HarnessNodeIncarnationV1::new("0".repeat(31)).is_err());
8511        assert!(HarnessNodeIncarnationV1::new("A".repeat(32)).is_err());
8512    }
8513
8514    #[test]
8515    fn runtime_inventory_request_validates() {
8516        let credential = HarnessOperatorCredential::parse(format!(
8517            "g4aho_{}",
8518            "a".repeat(64),
8519        )).unwrap();
8520        let request = HarnessOperatorRequestV1::RuntimeInventoryList {
8521            after_node_id: None,
8522            limit: HARNESS_RUNTIME_INVENTORY_PAGE_LIMIT_MAX,
8523        };
8524        assert!(HarnessOperatorEnvelopeV1 {
8525            build_stamp: BUILD_STAMP.to_string(),
8526            credential,
8527            request,
8528        }.validate().is_ok());
8529    }
8530
8531    /// Plan item 4 (`gate4agent-blocked-action-event-2026-09-02.md` §2): the
8532    /// fleet-wide `blocked_count`/`last_blocked_at_ms` instrument (added
8533    /// under the old hand-bumped wire-pin scheme; the envelope now checks
8534    /// `BUILD_STAMP` for exact equality instead, so this field's own arrival
8535    /// is no longer asserted by a version number here) round-trips through
8536    /// JSON intact, and its own invariant (a count of zero implies no
8537    /// timestamp, and vice versa) is enforced the same way every other
8538    /// paired count/timestamp field in this crate is.
8539    #[test]
8540    fn managed_session_blocked_stats_round_trip() {
8541        let mut session = sample_managed_session("record-a");
8542        session.blocked_count = 2;
8543        session.last_blocked_at_ms = Some(2_000);
8544        session.validate().expect("blocked_count with a timestamp is valid");
8545        let encoded = serde_json::to_string(&session).unwrap();
8546        let decoded: HarnessRuntimeManagedSessionV1 = serde_json::from_str(&encoded).unwrap();
8547        assert_eq!(decoded, session);
8548
8549        let mut zero_with_timestamp = sample_managed_session("record-a");
8550        zero_with_timestamp.last_blocked_at_ms = Some(1);
8551        assert!(zero_with_timestamp.validate().is_err());
8552
8553        let mut counted_without_timestamp = sample_managed_session("record-a");
8554        counted_without_timestamp.blocked_count = 1;
8555        assert!(counted_without_timestamp.validate().is_err());
8556    }
8557
8558    fn sample_runtime_inventory(
8559        managed_sessions: Vec<HarnessRuntimeManagedSessionV1>,
8560    ) -> HarnessRuntimeInventoryV1 {
8561        HarnessRuntimeInventoryV1 {
8562            enabled_providers: Vec::new(),
8563            workspaces: BTreeMap::new(),
8564            workspace_count: 0,
8565            workspaces_truncated: false,
8566            session_count: 0,
8567            sessions_truncated: false,
8568            managed_session_count: managed_sessions.len(),
8569            managed_sessions_truncated: false,
8570            managed_sessions,
8571            retired_count: 0,
8572            launch_inventory: None,
8573        }
8574    }
8575
8576    /// Regression for `ed4fa61`: the producer (`SlimNodeInventory::
8577    /// from_snapshot`/`from_c2_snapshot`, c2-protocol) orders a managed-
8578    /// session page by `managed_session_liveness_rank` (Live, IdentityPending,
8579    /// Dormant, Unavailable) first and `record_id` only within a rank, so a
8580    /// real page's `record_id`s are routinely NOT ascending overall. Before
8581    /// this fix `validate` demanded flat ascending `record_id` order and
8582    /// rejected every such page outright.
8583    #[test]
8584    fn managed_session_ordering_is_liveness_rank_then_record_id() {
8585        let mut live = sample_managed_session("zzz-live");
8586        live.state = HarnessRuntimeManagedStateV1::Live;
8587        let mut pending = sample_managed_session("aaa-pending");
8588        pending.state = HarnessRuntimeManagedStateV1::IdentityPending;
8589        let mut unavailable = sample_managed_session("mmm-unavailable");
8590        unavailable.state = HarnessRuntimeManagedStateV1::Unavailable;
8591
8592        let ordered = sample_runtime_inventory(vec![
8593            live.clone(),
8594            pending,
8595            unavailable,
8596        ]);
8597        ordered.validate().expect(
8598            "liveness-rank-then-record_id order validates even though record_id is not ascending overall",
8599        );
8600
8601        let duplicate = sample_runtime_inventory(vec![live.clone(), live.clone()]);
8602        assert!(duplicate.validate().is_err());
8603
8604        let mut dormant = sample_managed_session("aaa-dormant");
8605        dormant.state = HarnessRuntimeManagedStateV1::Dormant;
8606        let rank_out_of_order = sample_runtime_inventory(vec![dormant, live]);
8607        assert!(rank_out_of_order.validate().is_err());
8608    }
8609
8610    /// Slice R (`hatchery-node`'s session-record retention sweep):
8611    /// `retired_count` is additive, defaults to `0` for a pre-existing
8612    /// payload that never had it, and round-trips intact once a producer
8613    /// sets a real, nonzero value.
8614    #[test]
8615    fn runtime_inventory_retired_count_round_trips_and_defaults_to_zero() {
8616        let mut inventory = sample_runtime_inventory(Vec::new());
8617        assert_eq!(inventory.retired_count, 0);
8618        inventory.validate().expect("a fresh sample inventory is valid");
8619
8620        inventory.retired_count = 5;
8621        let encoded = serde_json::to_string(&inventory).unwrap();
8622        assert!(encoded.contains("\"retired_count\":5"));
8623        let decoded: HarnessRuntimeInventoryV1 = serde_json::from_str(&encoded).unwrap();
8624        assert_eq!(decoded, inventory);
8625        decoded.validate().expect("a nonzero retired_count is still a valid inventory");
8626    }
8627
8628    #[test]
8629    fn runtime_inventory_launch_inventory_round_trips_and_fails_closed() {
8630        let node = HarnessRuntimeNodeInventoryV1 {
8631            node_id: "node-a".to_owned(),
8632            incarnation_id: "07".repeat(16),
8633            observed_at_unix_ms: 1_000,
8634            event_sequence: 4,
8635            inventory: HarnessRuntimeInventoryV1 {
8636                enabled_providers: vec!["claude".to_owned()],
8637                workspaces: BTreeMap::new(),
8638                workspace_count: 0,
8639                workspaces_truncated: false,
8640                session_count: 0,
8641                sessions_truncated: false,
8642                managed_sessions: Vec::new(),
8643                managed_session_count: 0,
8644                managed_sessions_truncated: false,
8645                retired_count: 0,
8646                launch_inventory: Some(HarnessRuntimeLaunchInventoryV1 {
8647                    spawn_profiles: Some(vec![HarnessRuntimeSpawnProfileSummaryV1 {
8648                        id: "default".to_owned(),
8649                        revision: "rev-1".to_owned(),
8650                        environment_profile: Some(HarnessRuntimeEnvironmentProfileReceiptV1 {
8651                            profile_id: "env-a".to_owned(),
8652                            profile_revision: "env-rev-1".to_owned(),
8653                        }),
8654                    }]),
8655                    bundles: Some(vec![HarnessRuntimeBundleReceiptV1 {
8656                        id: "bundle-a".to_owned(),
8657                        revision: "bundle-rev-1".to_owned(),
8658                        digest: format!("sha256:{}", "a".repeat(64)),
8659                    }]),
8660                }),
8661            },
8662        };
8663        node.validate().expect("valid launch inventory");
8664        let encoded = serde_json::to_string(&node).unwrap();
8665        let decoded: HarnessRuntimeNodeInventoryV1 = serde_json::from_str(&encoded).unwrap();
8666        assert_eq!(decoded, node);
8667
8668        let mut missing_component = node.clone();
8669        missing_component.inventory.launch_inventory = Some(HarnessRuntimeLaunchInventoryV1 {
8670            spawn_profiles: None,
8671            bundles: None,
8672        });
8673        assert!(missing_component.validate().is_err());
8674
8675        let mut duplicate_profile = node.clone();
8676        let profile = duplicate_profile.inventory.launch_inventory.as_ref().unwrap()
8677            .spawn_profiles.as_ref().unwrap()[0].clone();
8678        duplicate_profile.inventory.launch_inventory.as_mut().unwrap()
8679            .spawn_profiles.as_mut().unwrap().push(profile);
8680        assert!(duplicate_profile.validate().is_err());
8681
8682        let mut bad_digest = node.clone();
8683        bad_digest.inventory.launch_inventory.as_mut().unwrap()
8684            .bundles.as_mut().unwrap()[0].digest = "not-a-digest".to_owned();
8685        assert!(bad_digest.validate().is_err());
8686
8687        let mut no_launch_inventory = node;
8688        no_launch_inventory.inventory.launch_inventory = None;
8689        no_launch_inventory.validate().expect("absent launch inventory stays valid");
8690    }
8691
8692    #[test]
8693    fn native_history_is_exact_bounded_and_excludes_sensitive_fields() {
8694        let credential = HarnessOperatorCredential::parse(format!(
8695            "g4aho_{}",
8696            "a".repeat(64),
8697        )).unwrap();
8698        let route = HarnessNativeSessionRouteV1 {
8699            node_id: "node-a".to_owned(),
8700            incarnation_id: "1".repeat(32),
8701            scope: HarnessNativeSessionCatalogScopeV1::Workspace,
8702            workspace_id: Some("workspace-a".to_owned()),
8703            provider: "codex".to_owned(),
8704        };
8705        let selection = HarnessNativeSessionSelectionV1 {
8706            route: route.clone(),
8707            catalog_revision: 7,
8708            recent_cutoff_unix_ms: 9,
8709            selection_id: "selection-a".to_owned(),
8710        };
8711        for request in [
8712            HarnessOperatorRequestV1::CatalogNativeSessions {
8713                route: route.clone(),
8714                limit: HARNESS_NATIVE_SESSION_CATALOG_LIMIT_MAX,
8715            },
8716            HarnessOperatorRequestV1::PageNativeSessions {
8717                route: route.clone(),
8718                window: HarnessNativeSessionCatalogWindowV1::Recent,
8719                catalog_revision: 7,
8720                recent_cutoff_unix_ms: 9,
8721                after_selection_id: Some("selection-a".to_owned()),
8722                limit: 1,
8723            },
8724            HarnessOperatorRequestV1::PreviewNativeSession {
8725                selection: selection.clone(),
8726                message_limit: HARNESS_NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX,
8727            },
8728        ] {
8729            HarnessOperatorEnvelopeV1 {
8730                build_stamp: BUILD_STAMP.to_string(),
8731                credential: credential.clone(),
8732                request,
8733            }.validate().unwrap();
8734        }
8735        let response = HarnessOperatorResponseV1::NativeSessionPreviewed(
8736            HarnessNativeSessionPreviewedV1 {
8737                selection,
8738                preview: HarnessNativeSessionPreviewV1 {
8739                    title: Some("Bounded preview".to_owned()),
8740                    modified_at_unix_ms: Some(10),
8741                    model: Some("model-a".to_owned()),
8742                    message_count: 1,
8743                    message_count_exact: true,
8744                    completed_turn_count: Some(1),
8745                    total_tokens: Some(12),
8746                    truncated: false,
8747                    messages: vec![HarnessNativeSessionPreviewMessageV1 {
8748                        role: HarnessNativeSessionPreviewRoleV1::User,
8749                        text: "bounded history preview".to_owned(),
8750                    }],
8751                },
8752            },
8753        );
8754        response.validate().unwrap();
8755        let encoded = serde_json::to_string(&response).unwrap();
8756        for forbidden in [
8757            "g4aho_",
8758            "credential",
8759            "provider_identity",
8760            "session_id",
8761            "terminal",
8762            "cwd",
8763        ] {
8764            assert!(!encoded.contains(forbidden));
8765        }
8766    }
8767
8768    #[test]
8769    fn unknown_request_fields_and_unbounded_limits_fail_closed() {
8770        let unknown = r#"{"kind":"tasks-list","limit":10,"cursor":"secret"}"#;
8771        assert!(serde_json::from_str::<HarnessReadRequestV1>(unknown).is_err());
8772        assert!(HarnessReadRequestV1::TasksList {
8773            after_task_id: None, state: None, parent_task_id: None, limit: 0,
8774        }.validate().is_err());
8775        assert!(HarnessReadRequestV1::TasksList {
8776            after_task_id: None, state: None, parent_task_id: None, limit: 65,
8777        }.validate().is_err());
8778    }
8779
8780    #[test]
8781    fn redacted_cross_entity_references_do_not_require_hidden_ids() {
8782        let redacted_run = r#"{
8783            "run_id":"hrun_000000000000000000000001",
8784            "revision":1,
8785            "parent_run_id":null,
8786            "task_id":null,
8787            "operation_id":null,
8788            "intent":{"mode":"inline","worktree":"existing","has_delivery_bundle":false,"has_continuation":false},
8789            "lifecycle":"requested",
8790            "binding":"none",
8791            "result_disposition":null,
8792            "failure_category":null,
8793            "delivery_receipt":null,
8794            "references_redacted":true,
8795            "created_at_unix_ms":1,
8796            "updated_at_unix_ms":1
8797        }"#;
8798        let run: RedactedRunV1 = serde_json::from_str(redacted_run).expect("redacted run");
8799        run.validate().expect("valid redacted run");
8800
8801        let operation_with_grant = r#"{
8802            "operation_id":"hop_000000000000000000000001",
8803            "revision":1,
8804            "kind":"create-task",
8805            "state":"prepared",
8806            "task_id":null,
8807            "run_id":null,
8808            "grant_id":"hgrant_000000000000000000000001",
8809            "reconciles_operation_id":null,
8810            "references_redacted":true,
8811            "failure_category":null,
8812            "outcome_unknown_reason":null,
8813            "reconciliation_outcome":null,
8814            "created_at_unix_ms":1,
8815            "updated_at_unix_ms":1,
8816            "dispatched_at_unix_ms":null,
8817            "finished_at_unix_ms":null
8818        }"#;
8819        assert!(serde_json::from_str::<RedactedOperationV1>(operation_with_grant).is_err());
8820    }
8821
8822    #[test]
8823    fn operator_run_workspace_requests_are_exact_harness_only_round_trips() {
8824        let run_id = run_workspace_origin().run_id;
8825        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
8826        let object_id = HarnessGitObjectIdV1::new("a".repeat(40)).unwrap();
8827        let requests = vec![
8828            HarnessOperatorRequestV1::InspectRunWorkspace {
8829                run_id: run_id.clone(),
8830            },
8831            HarnessOperatorRequestV1::ReadRunWorkspaceFile {
8832                run_id: run_id.clone(),
8833                path: path.clone(),
8834            },
8835            HarnessOperatorRequestV1::ReadRunGitHistory {
8836                run_id: run_id.clone(),
8837                path: Some(path.clone()),
8838                before: Some(object_id.clone()),
8839                limit: HARNESS_GIT_HISTORY_LIMIT_MAX,
8840            },
8841            HarnessOperatorRequestV1::ReadRunGitDiff {
8842                run_id,
8843                mode: HarnessGitDiffModeV1::Commit { revision: object_id },
8844                path: Some(path),
8845            },
8846        ];
8847        for request in requests {
8848            request.validate().expect("valid workspace request");
8849            let encoded = serde_json::to_string(&request).unwrap();
8850            for forbidden in [
8851                "node_id",
8852                "workspace_id",
8853                "endpoint",
8854                "root",
8855                "workspace_root",
8856                "worktree",
8857                "worktree_path",
8858                "environment",
8859                "spawn_spec",
8860                "provider_home",
8861            ] {
8862                assert!(!encoded.contains(forbidden), "request exposed {forbidden}");
8863            }
8864            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
8865            assert_eq!(decoded, request);
8866        }
8867    }
8868
8869    #[test]
8870    fn operator_v4_workspace_replies_round_trip_without_host_or_diagnostic_fields() {
8871        let origin = run_workspace_origin();
8872        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
8873        let commit = git_commit('a');
8874        let responses = vec![
8875            HarnessOperatorResponseV1::RunWorkspaceInspected(
8876                HarnessRunWorkspaceInspectionV1 {
8877                    origin: origin.clone(),
8878                    entries: vec![HarnessWorkspaceTreeEntryV1 {
8879                        relative_path: path.clone(),
8880                        kind: HarnessWorkspaceEntryKindV1::File,
8881                    }],
8882                    tree_truncated: false,
8883                    git: HarnessGitSummaryV1 {
8884                        is_repository: true,
8885                        branch: Some("main".to_owned()),
8886                        status: vec![HarnessGitStatusEntryV1 {
8887                            index_status: HarnessGitStatusCodeV1::Unmodified,
8888                            worktree_status: HarnessGitStatusCodeV1::Modified,
8889                            path: path.clone(),
8890                            previous_path: None,
8891                        }],
8892                        recent_commits: vec![HarnessGitCommitSummaryV1 {
8893                            id: commit.id.clone(),
8894                            summary: commit.subject.clone(),
8895                        }],
8896                        truncated: false,
8897                    },
8898                    truncation: None,
8899                },
8900            ),
8901            HarnessOperatorResponseV1::RunWorkspaceFileRead(HarnessRunWorkspaceFileV1 {
8902                origin: origin.clone(),
8903                path: path.clone(),
8904                content: HarnessWorkspaceFileContentV1::Utf8 {
8905                    text: "fn main() {}\n".to_owned(),
8906                    byte_len: 13,
8907                },
8908                revision: Some(HarnessWorkspaceFileRevisionV1::new("b".repeat(64)).unwrap()),
8909            }),
8910            HarnessOperatorResponseV1::RunGitHistoryRead(HarnessRunGitHistoryPageV1 {
8911                origin: origin.clone(),
8912                path: Some(path.clone()),
8913                commits: vec![commit],
8914                next_before: None,
8915                truncated: false,
8916            }),
8917            HarnessOperatorResponseV1::RunGitDiffRead(HarnessRunGitDiffV1 {
8918                origin,
8919                mode: HarnessGitDiffModeV1::Working,
8920                path: Some(path),
8921                text: "diff --git a/src/lib.rs b/src/lib.rs\n".to_owned(),
8922                truncated: false,
8923            }),
8924        ];
8925        for response in responses {
8926            response.validate().expect("valid V4 workspace response");
8927            let encoded = serde_json::to_string(&response).unwrap();
8928            assert_json_has_no_forbidden_keys(&encoded, &[
8929                "root",
8930                "worktree",
8931                "endpoint",
8932                "diagnostic",
8933                "author_email",
8934                "committer_email",
8935                "environment",
8936                "spawn_spec",
8937                "provider_home",
8938            ]);
8939            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
8940            assert_eq!(decoded, response);
8941        }
8942    }
8943
8944    #[test]
8945    fn operator_v4_repository_paths_and_git_cursors_fail_closed() {
8946        for value in [
8947            "",
8948            ".",
8949            "..",
8950            "../secret",
8951            "src/../secret",
8952            "src//lib.rs",
8953            "/absolute",
8954            "C:/absolute",
8955            r"src\lib.rs",
8956            "src/\nlib.rs",
8957        ] {
8958            assert!(HarnessRepositoryPathV1::new(value).is_err(), "accepted {value:?}");
8959        }
8960        assert!(HarnessRepositoryPathV1::new("x".repeat(HARNESS_REPOSITORY_PATH_MAX_BYTES)).is_ok());
8961        assert!(HarnessRepositoryPathV1::new("x".repeat(HARNESS_REPOSITORY_PATH_MAX_BYTES + 1)).is_err());
8962        assert!(HarnessGitObjectIdV1::new("a".repeat(40)).is_ok());
8963        assert!(HarnessGitObjectIdV1::new("a".repeat(64)).is_ok());
8964        assert!(HarnessGitObjectIdV1::new("A".repeat(40)).is_err());
8965        assert!(HarnessGitObjectIdV1::new("a".repeat(39)).is_err());
8966
8967        let unknown_route = format!(
8968            r#"{{"kind":"read-run-workspace-file","run_id":"hrun_{}","path":"src/lib.rs","workspace_id":"forbidden"}}"#,
8969            "a".repeat(24),
8970        );
8971        assert!(serde_json::from_str::<HarnessOperatorRequestV1>(&unknown_route).is_err());
8972    }
8973
8974    #[test]
8975    fn operator_v4_workspace_payloads_enforce_exact_bounds() {
8976        let origin = run_workspace_origin();
8977        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
8978        let at_file_limit = HarnessWorkspaceFileContentV1::Utf8 {
8979            text: "x".repeat(HARNESS_WORKSPACE_FILE_MAX_BYTES),
8980            byte_len: HARNESS_WORKSPACE_FILE_MAX_BYTES as u32,
8981        };
8982        assert!(at_file_limit.validate().is_ok());
8983        assert!(HarnessWorkspaceFileContentV1::Utf8 {
8984            text: "x".repeat(HARNESS_WORKSPACE_FILE_MAX_BYTES + 1),
8985            byte_len: (HARNESS_WORKSPACE_FILE_MAX_BYTES + 1) as u32,
8986        }.validate().is_err());
8987        assert!(HarnessRunGitDiffV1 {
8988            origin: origin.clone(),
8989            mode: HarnessGitDiffModeV1::Working,
8990            path: Some(path.clone()),
8991            text: "x".repeat(HARNESS_GIT_DIFF_MAX_BYTES),
8992            truncated: false,
8993        }.validate().is_ok());
8994        assert!(HarnessRunGitDiffV1 {
8995            origin: origin.clone(),
8996            mode: HarnessGitDiffModeV1::Working,
8997            path: Some(path.clone()),
8998            text: "x".repeat(HARNESS_GIT_DIFF_MAX_BYTES + 1),
8999            truncated: true,
9000        }.validate().is_err());
9001        assert!(HarnessOperatorRequestV1::ReadRunGitHistory {
9002            run_id: origin.run_id.clone(),
9003            path: Some(path.clone()),
9004            before: None,
9005            limit: HARNESS_GIT_HISTORY_LIMIT_MAX + 1,
9006        }.validate().is_err());
9007
9008        let entries: Vec<_> = (0..HARNESS_WORKSPACE_TREE_ENTRIES_MAX)
9009            .map(|index| HarnessWorkspaceTreeEntryV1 {
9010                relative_path: HarnessRepositoryPathV1::new(format!("entry-{index:04}")).unwrap(),
9011                kind: HarnessWorkspaceEntryKindV1::File,
9012            })
9013            .collect();
9014        let empty_git = HarnessGitSummaryV1 {
9015            is_repository: false,
9016            branch: None,
9017            status: Vec::new(),
9018            recent_commits: Vec::new(),
9019            truncated: false,
9020        };
9021        assert!(HarnessRunWorkspaceInspectionV1 {
9022            origin: origin.clone(),
9023            entries: entries.clone(),
9024            tree_truncated: false,
9025            git: empty_git.clone(),
9026            truncation: None,
9027        }.validate().is_ok());
9028        let mut too_many_entries = entries;
9029        too_many_entries.push(HarnessWorkspaceTreeEntryV1 {
9030            relative_path: HarnessRepositoryPathV1::new("entry-0512").unwrap(),
9031            kind: HarnessWorkspaceEntryKindV1::File,
9032        });
9033        assert!(HarnessRunWorkspaceInspectionV1 {
9034            origin: origin.clone(),
9035            entries: too_many_entries,
9036            tree_truncated: true,
9037            git: empty_git,
9038            truncation: None,
9039        }.validate().is_err());
9040
9041        let status: Vec<_> = (0..HARNESS_GIT_STATUS_ENTRIES_MAX)
9042            .map(|index| HarnessGitStatusEntryV1 {
9043                index_status: HarnessGitStatusCodeV1::Unmodified,
9044                worktree_status: HarnessGitStatusCodeV1::Modified,
9045                path: HarnessRepositoryPathV1::new(format!("status-{index:04}")).unwrap(),
9046                previous_path: None,
9047            })
9048            .collect();
9049        let recent_commits: Vec<_> = (0..HARNESS_GIT_RECENT_COMMITS_MAX)
9050            .map(|index| HarnessGitCommitSummaryV1 {
9051                id: HarnessGitObjectIdV1::new(format!("{index:040x}")).unwrap(),
9052                summary: format!("commit {index}"),
9053            })
9054            .collect();
9055        assert!(HarnessGitSummaryV1 {
9056            is_repository: true,
9057            branch: Some("main".to_owned()),
9058            status: status.clone(),
9059            recent_commits: recent_commits.clone(),
9060            truncated: false,
9061        }.validate().is_ok());
9062        let mut too_many_status = status;
9063        too_many_status.push(HarnessGitStatusEntryV1 {
9064            index_status: HarnessGitStatusCodeV1::Unmodified,
9065            worktree_status: HarnessGitStatusCodeV1::Modified,
9066            path: HarnessRepositoryPathV1::new("status-0128").unwrap(),
9067            previous_path: None,
9068        });
9069        assert!(HarnessGitSummaryV1 {
9070            is_repository: true,
9071            branch: Some("main".to_owned()),
9072            status: too_many_status,
9073            recent_commits: Vec::new(),
9074            truncated: true,
9075        }.validate().is_err());
9076        let mut too_many_recent = recent_commits;
9077        too_many_recent.push(HarnessGitCommitSummaryV1 {
9078            id: HarnessGitObjectIdV1::new(format!("{:040x}", HARNESS_GIT_RECENT_COMMITS_MAX)).unwrap(),
9079            summary: "one too many".to_owned(),
9080        });
9081        assert!(HarnessGitSummaryV1 {
9082            is_repository: true,
9083            branch: Some("main".to_owned()),
9084            status: Vec::new(),
9085            recent_commits: too_many_recent,
9086            truncated: true,
9087        }.validate().is_err());
9088
9089        let commits: Vec<_> = (0..HARNESS_GIT_HISTORY_LIMIT_MAX)
9090            .map(|index| {
9091                let mut commit = git_commit('a');
9092                commit.id = HarnessGitObjectIdV1::new(format!("{index:040x}")).unwrap();
9093                commit
9094            })
9095            .collect();
9096        assert!(HarnessRunGitHistoryPageV1 {
9097            origin: origin.clone(),
9098            path: Some(path.clone()),
9099            commits: commits.clone(),
9100            next_before: None,
9101            truncated: false,
9102        }.validate().is_ok());
9103        let next_before = commits.last().map(|commit| commit.id.clone());
9104        assert!(HarnessRunGitHistoryPageV1 {
9105            origin: origin.clone(),
9106            path: Some(path.clone()),
9107            commits: commits.clone(),
9108            next_before: next_before.clone(),
9109            truncated: false,
9110        }.validate().is_ok());
9111        assert!(HarnessRunGitHistoryPageV1 {
9112            origin: origin.clone(),
9113            path: Some(path.clone()),
9114            commits: commits.clone(),
9115            next_before: None,
9116            truncated: true,
9117        }.validate().is_ok());
9118        assert!(HarnessRunGitHistoryPageV1 {
9119            origin: origin.clone(),
9120            path: Some(path.clone()),
9121            commits: commits.clone(),
9122            next_before,
9123            truncated: true,
9124        }.validate().is_ok());
9125        let mut too_many_commits = commits;
9126        let mut extra_commit = git_commit('a');
9127        extra_commit.id = HarnessGitObjectIdV1::new(format!("{:040x}", HARNESS_GIT_HISTORY_LIMIT_MAX)).unwrap();
9128        too_many_commits.push(extra_commit);
9129        assert!(HarnessRunGitHistoryPageV1 {
9130            origin,
9131            path: Some(path),
9132            commits: too_many_commits,
9133            next_before: None,
9134            truncated: false,
9135        }.validate().is_err());
9136    }
9137
9138    fn monitor_with_mixed_capabilities() -> SessionMonitorV1 {
9139        SessionMonitorV1 {
9140            run_id: HarnessRunId::new("hrun_000000000000000000000001").unwrap(),
9141            visibility: HarnessMonitoringVisibilityV1::Detail,
9142            availability: ProjectionAvailabilityV1::Frozen,
9143            freshness: ProjectionFreshnessV1::ReplacedIncarnation,
9144            transport_incomplete: false,
9145            features: MonitorFeatureStatesV1 {
9146                todo: FeatureObservationStateV1::Observed,
9147                tools: FeatureObservationStateV1::NotSupportedByObservedSources,
9148                subagents: FeatureObservationStateV1::Unknown,
9149                interactions: FeatureObservationStateV1::SupportedNotObserved,
9150                owned_processes: FeatureObservationStateV1::Observed,
9151                files: FeatureObservationStateV1::Observed,
9152                usage: FeatureObservationStateV1::SupportedNotObserved,
9153                history: FeatureObservationStateV1::Unknown,
9154            },
9155            todo_total: 1,
9156            todo_completed: 0,
9157            active_tools: 0,
9158            active_subagents: 0,
9159            active_interactions: 0,
9160            active_blocks: 0,
9161            active_processes: 0,
9162            input_tokens: 0,
9163            output_tokens: 0,
9164            cache_read_tokens: 0,
9165            cache_write_tokens: 0,
9166            reasoning_tokens: 0,
9167            context_window_tokens: None,
9168            history: None,
9169            detail: Some(SessionMonitorDetailV1 {
9170                todo_facts: vec![TodoFactV1 {
9171                    state: TodoStateV1::Unknown,
9172                    todo_id: Some("todo-1".to_owned()),
9173                    label: Some("review result".to_owned()),
9174                    evidence: ObservationEvidenceV1::PtyHint,
9175                }],
9176                tool_facts: Vec::new(),
9177                subagent_facts: Vec::new(),
9178                interaction_facts: Vec::new(),
9179                block_facts: Vec::new(),
9180                process_facts: vec![ActivityFactV1 {
9181                    class: ActivityClassV1::OwnedProcess,
9182                    state: ActivityStateV1::UnknownAfterGap,
9183                    label: Some("compiler".to_owned()),
9184                    correlation: Some(1),
9185                    evidence: ObservationEvidenceV1::ManagedHook,
9186                }],
9187                file_facts: vec![FileFactV1 {
9188                    action: FileActionV1::Changed,
9189                    relative_path: Some("src/lib.rs".to_owned()),
9190                    evidence: ObservationEvidenceV1::StructuredProvider,
9191                }],
9192            }),
9193        }
9194    }
9195
9196    #[test]
9197    fn monitor_preserves_mixed_capability_and_frozen_replaced_truth() {
9198        let monitor = monitor_with_mixed_capabilities();
9199        monitor.validate().expect("truthful mixed monitor");
9200        let encoded = serde_json::to_string(&monitor).expect("encode monitor");
9201        let decoded: SessionMonitorV1 = serde_json::from_str(&encoded).expect("decode monitor");
9202        assert_eq!(decoded, monitor);
9203        assert_eq!(decoded.availability, ProjectionAvailabilityV1::Frozen);
9204        assert_eq!(decoded.freshness, ProjectionFreshnessV1::ReplacedIncarnation);
9205        assert_eq!(decoded.features.tools, FeatureObservationStateV1::NotSupportedByObservedSources);
9206        let detail = decoded.detail.unwrap();
9207        assert_eq!(detail.todo_facts[0].state, TodoStateV1::Unknown);
9208        assert_eq!(detail.todo_facts[0].todo_id.as_deref(), Some("todo-1"));
9209        assert_eq!(detail.todo_facts[0].label.as_deref(), Some("review result"));
9210        assert_eq!(detail.process_facts[0].correlation, Some(1));
9211        assert_eq!(detail.file_facts[0].relative_path.as_deref(), Some("src/lib.rs"));
9212    }
9213
9214    #[test]
9215    fn monitor_rejects_inference_and_raw_detail_sentinels() {
9216        let mut monitor = monitor_with_mixed_capabilities();
9217        monitor.detail.as_mut().unwrap().tool_facts.push(ActivityFactV1 {
9218            class: ActivityClassV1::Tool,
9219            state: ActivityStateV1::Active,
9220            label: Some("command".to_owned()),
9221            correlation: Some(1),
9222            evidence: ObservationEvidenceV1::StructuredProvider,
9223        });
9224        assert!(monitor.validate().is_err());
9225
9226        let raw_file = r#"{"action":"changed","evidence":"managed-hook","path":"C:\\private\\prompt.txt"}"#;
9227        assert!(serde_json::from_str::<FileFactV1>(raw_file).is_err());
9228        let encoded = serde_json::to_string(&monitor_with_mixed_capabilities()).unwrap();
9229        for sentinel in ["private", "prompt.txt", "provider_id", "correlation_id"] {
9230            assert!(!encoded.contains(sentinel));
9231        }
9232
9233        let mut partial = monitor_with_mixed_capabilities();
9234        partial.availability = ProjectionAvailabilityV1::Partial;
9235        partial.freshness = ProjectionFreshnessV1::IncompleteAfterGap;
9236        partial.transport_incomplete = true;
9237        partial.validate().expect("truthful partial gap");
9238    }
9239
9240    #[test]
9241    fn timeline_structured_fields_round_trip_without_raw_correlation_shape() {
9242        let run_id = HarnessRunId::new("hrun_000000000000000000000001").unwrap();
9243        let page = TimelinePageV1 {
9244            run_id: run_id.clone(),
9245            availability: ProjectionAvailabilityV1::Current,
9246            freshness: ProjectionFreshnessV1::Live,
9247            transport_incomplete: false,
9248            entries: vec![TimelineEntryV1 {
9249                sequence: 7,
9250                received_at_ms: 10,
9251                category: TimelineCategoryV1::Tool,
9252                label: Some("command".to_owned()),
9253                state: TimelineStateV1::Completed,
9254                correlation: Some(1),
9255                evidence: ObservationEvidenceV1::StructuredProvider,
9256            }],
9257            next_cursor: None,
9258        };
9259        page.validate_for(&run_id).unwrap();
9260        let encoded = serde_json::to_string(&page).unwrap();
9261        assert!(encoded.contains("\"label\":\"command\""));
9262        assert!(encoded.contains("\"state\":\"completed\""));
9263        assert!(encoded.contains("\"correlation\":1"));
9264        assert!(!encoded.contains("correlation_id"));
9265        assert_eq!(serde_json::from_str::<TimelinePageV1>(&encoded).unwrap(), page);
9266
9267        let old_entry: TimelineEntryV1 = serde_json::from_str(
9268            r#"{"sequence":1,"received_at_ms":2,"category":"usage","evidence":"managed-hook"}"#,
9269        ).unwrap();
9270        assert_eq!(old_entry.state, TimelineStateV1::Unknown);
9271        assert_eq!(old_entry.correlation, None);
9272        old_entry.validate().unwrap();
9273    }
9274
9275    #[test]
9276    fn monitor_history_summary_matches_categorical_feature_state() {
9277        let mut monitor = monitor_with_mixed_capabilities();
9278        monitor.features.history = FeatureObservationStateV1::Observed;
9279        monitor.history = Some(SessionMonitorHistoryV1 {
9280            message_count: 12,
9281            message_count_exact: true,
9282            completed_turn_count: Some(3),
9283            total_tokens: Some(800),
9284        });
9285        monitor.validate().unwrap();
9286        let encoded = serde_json::to_string(&monitor).unwrap();
9287        let decoded: SessionMonitorV1 = serde_json::from_str(&encoded).unwrap();
9288        assert_eq!(decoded.history, monitor.history);
9289
9290        monitor.history = None;
9291        monitor.validate().expect("redacted observed history remains valid");
9292        monitor.features.history = FeatureObservationStateV1::NotSupportedByObservedSources;
9293        monitor.history = Some(SessionMonitorHistoryV1 {
9294            message_count: 1,
9295            message_count_exact: false,
9296            completed_turn_count: None,
9297            total_tokens: None,
9298        });
9299        assert!(monitor.validate().is_err());
9300    }
9301
9302    #[test]
9303    fn structured_file_fact_rejects_absolute_or_traversing_paths() {
9304        for relative_path in ["C:/private.txt", "/private.txt", "src/../private.txt", "src\\private.txt"] {
9305            let fact = FileFactV1 {
9306                action: FileActionV1::Changed,
9307                relative_path: Some(relative_path.to_owned()),
9308                evidence: ObservationEvidenceV1::WorkspaceObservation,
9309            };
9310            assert!(fact.validate().is_err(), "accepted {relative_path}");
9311        }
9312    }
9313
9314    #[test]
9315    fn operator_reverse_attribution_is_exact_and_private() {
9316        let subject = HarnessReverseAttributionSubjectV1::FileScope {
9317            workspace: reverse_attribution_workspace(),
9318            relative_path: HarnessRepositoryPathV1::new("src/lib.rs").unwrap(),
9319        };
9320        let request = HarnessOperatorRequestV1::ReverseAttributionGet {
9321            subject: subject.clone(),
9322        };
9323        request.validate().unwrap();
9324        let credential = HarnessOperatorCredential::parse(format!(
9325            "g4aho_{}",
9326            "a".repeat(64),
9327        )).unwrap();
9328        HarnessOperatorEnvelopeV1 {
9329            build_stamp: BUILD_STAMP.to_string(),
9330            credential,
9331            request,
9332        }.validate().unwrap();
9333
9334        let response = HarnessReverseAttributionV1 {
9335            subject,
9336            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9337            links: vec![reverse_attribution_link('2')],
9338        };
9339        response.validate().unwrap();
9340        let encoded = serde_json::to_value(
9341            HarnessOperatorResponseV1::ReverseAttribution(response),
9342        ).unwrap();
9343        assert_eq!(
9344            encoded,
9345            serde_json::json!({
9346                "kind": "reverse-attribution",
9347                "value": {
9348                    "subject": {
9349                        "kind": "file-scope",
9350                        "workspace": {
9351                            "node_id": "node-a",
9352                            "node_incarnation_id": "07070707070707070707070707070707",
9353                            "workspace_id": "workspace-a"
9354                        },
9355                        "relative_path": "src/lib.rs"
9356                    },
9357                    "outcome": "attributed",
9358                    "links": [{
9359                        "task_id": "htask_111111111111111111111111",
9360                        "run_id": "hrun_222222222222222222222222",
9361                        "run_revision": 3,
9362                        "binding": {
9363                            "kind": "workspace",
9364                            "workspace": {
9365                                "node_id": "node-a",
9366                                "node_incarnation_id": "07070707070707070707070707070707",
9367                                "workspace_id": "workspace-a"
9368                            }
9369                        },
9370                        "relation": "workspace-scope"
9371                    }]
9372                }
9373            }),
9374        );
9375        let encoded = encoded.to_string();
9376        for forbidden in [
9377            "produced-by",
9378            "modified-by",
9379            "provider_session",
9380            "provider_profile",
9381            "provider_home",
9382            "credential",
9383            "auth",
9384            "canonical_root",
9385            "display_root",
9386            "source_path",
9387        ] {
9388            assert!(!encoded.contains(forbidden), "leaked {forbidden}");
9389        }
9390
9391        let mut unknown = serde_json::to_value(HarnessOperatorRequestV1::ReverseAttributionGet {
9392            subject: HarnessReverseAttributionSubjectV1::Workspace {
9393                workspace: reverse_attribution_workspace(),
9394            },
9395        }).unwrap();
9396        unknown["subject"]["provider_session_id"] = serde_json::json!("forbidden");
9397        assert!(serde_json::from_value::<HarnessOperatorRequestV1>(unknown).is_err());
9398    }
9399
9400    #[test]
9401    fn reverse_attribution_is_bounded_canonical_and_exactly_correlated() {
9402        let subject = HarnessReverseAttributionSubjectV1::FileScope {
9403            workspace: reverse_attribution_workspace(),
9404            relative_path: HarnessRepositoryPathV1::new("src/lib.rs").unwrap(),
9405        };
9406        let first = reverse_attribution_link('2');
9407        let second = reverse_attribution_link('3');
9408        let exact = HarnessReverseAttributionV1 {
9409            subject: subject.clone(),
9410            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9411            links: vec![first.clone(), second.clone()],
9412        };
9413        exact.validate_for(&subject).unwrap();
9414
9415        let mut noncanonical = exact.clone();
9416        noncanonical.links.reverse();
9417        assert!(matches!(
9418            noncanonical.validate(),
9419            Err(HarnessOperatorApiError::InvalidReverseAttribution),
9420        ));
9421        let duplicate = HarnessReverseAttributionV1 {
9422            links: vec![first.clone(), first],
9423            ..exact.clone()
9424        };
9425        assert!(duplicate.validate().is_err());
9426        let over_limit = HarnessReverseAttributionV1 {
9427            links: (0..=HARNESS_REVERSE_ATTRIBUTION_LINKS_MAX)
9428                .map(|index| HarnessReverseAttributionLinkV1 {
9429                    task_id: HarnessTaskId::new(format!("htask_{index:024x}")).unwrap(),
9430                    ..reverse_attribution_link('4')
9431                })
9432                .collect(),
9433            ..exact.clone()
9434        };
9435        assert!(over_limit.validate().is_err());
9436
9437        let unattributed = HarnessReverseAttributionV1 {
9438            subject: subject.clone(),
9439            outcome: HarnessReverseAttributionOutcomeV1::Unattributed,
9440            links: Vec::new(),
9441        };
9442        unattributed.validate().unwrap();
9443        let attributed_empty = HarnessReverseAttributionV1 {
9444            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9445            ..unattributed.clone()
9446        };
9447        assert!(attributed_empty.validate().is_err());
9448        let unattributed_linked = HarnessReverseAttributionV1 {
9449            outcome: HarnessReverseAttributionOutcomeV1::Unattributed,
9450            links: vec![second],
9451            ..exact.clone()
9452        };
9453        assert!(unattributed_linked.validate().is_err());
9454
9455        let other_subject = HarnessReverseAttributionSubjectV1::FileScope {
9456            workspace: reverse_attribution_workspace(),
9457            relative_path: HarnessRepositoryPathV1::new("src/other.rs").unwrap(),
9458        };
9459        assert!(matches!(
9460            exact.validate_for(&other_subject),
9461            Err(HarnessOperatorApiError::InvalidReverseAttribution),
9462        ));
9463    }
9464
9465    #[test]
9466    fn reverse_attribution_relations_reject_false_file_and_binding_claims() {
9467        let file_subject = HarnessReverseAttributionSubjectV1::FileScope {
9468            workspace: reverse_attribution_workspace(),
9469            relative_path: HarnessRepositoryPathV1::new("src/lib.rs").unwrap(),
9470        };
9471        let mut wrong_relation = reverse_attribution_link('2');
9472        wrong_relation.relation = HarnessReverseAttributionRelationV1::WorkspaceBinding;
9473        assert!(HarnessReverseAttributionV1 {
9474            subject: file_subject,
9475            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9476            links: vec![wrong_relation],
9477        }.validate().is_err());
9478
9479        let managed_subject = HarnessReverseAttributionSubjectV1::ManagedRecord {
9480            workspace: reverse_attribution_workspace(),
9481            record_id: HarnessSelectorV1::new("record-a").unwrap(),
9482        };
9483        let mismatched_record = HarnessReverseAttributionLinkV1 {
9484            task_id: HarnessTaskId::new(format!("htask_{}", "1".repeat(24))).unwrap(),
9485            run_id: HarnessRunId::new(format!("hrun_{}", "2".repeat(24))).unwrap(),
9486            run_revision: HarnessRevision::new(3).unwrap(),
9487            binding: HarnessReverseAttributionBindingV1::ManagedRecord {
9488                workspace: reverse_attribution_workspace(),
9489                record_id: HarnessSelectorV1::new("record-b").unwrap(),
9490                active_instance_id: Some(41),
9491                active_generation: Some(3),
9492            },
9493            relation: HarnessReverseAttributionRelationV1::ManagedRecordBinding,
9494        };
9495        assert!(HarnessReverseAttributionV1 {
9496            subject: managed_subject,
9497            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9498            links: vec![mismatched_record],
9499        }.validate().is_err());
9500
9501        let invalid_active_pair = HarnessReverseAttributionBindingV1::ManagedRecord {
9502            workspace: reverse_attribution_workspace(),
9503            record_id: HarnessSelectorV1::new("record-a").unwrap(),
9504            active_instance_id: Some(41),
9505            active_generation: None,
9506        };
9507        assert!(invalid_active_pair.validate().is_err());
9508    }
9509
9510    #[test]
9511    fn operator_node_workspace_requests_are_exact_round_trips() {
9512        let node_id = node_workspace_origin().node_id;
9513        let workspace_id = node_workspace_origin().workspace_id;
9514        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
9515        let object_id = HarnessGitObjectIdV1::new("a".repeat(40)).unwrap();
9516        let requests = vec![
9517            HarnessOperatorRequestV1::InspectNodeWorkspace {
9518                node_id: node_id.clone(),
9519                workspace_id: workspace_id.clone(),
9520            },
9521            HarnessOperatorRequestV1::ReadNodeWorkspaceFile {
9522                node_id: node_id.clone(),
9523                workspace_id: workspace_id.clone(),
9524                path: path.clone(),
9525            },
9526            HarnessOperatorRequestV1::ReadNodeGitHistory {
9527                node_id: node_id.clone(),
9528                workspace_id: workspace_id.clone(),
9529                path: Some(path.clone()),
9530                before: Some(object_id.clone()),
9531                limit: HARNESS_GIT_HISTORY_LIMIT_MAX,
9532            },
9533            HarnessOperatorRequestV1::ReadNodeGitDiff {
9534                node_id: node_id.clone(),
9535                workspace_id: workspace_id.clone(),
9536                mode: HarnessGitDiffModeV1::Commit { revision: object_id },
9537                path: Some(path),
9538            },
9539        ];
9540        let credential = HarnessOperatorCredential::parse(format!(
9541            "g4aho_{}",
9542            "a".repeat(64),
9543        )).unwrap();
9544        for request in requests {
9545            request.validate().expect("valid node-workspace request");
9546            let encoded = serde_json::to_string(&request).unwrap();
9547            for forbidden in ["run_id", "endpoint", "root", "worktree", "environment"] {
9548                assert!(!encoded.contains(forbidden), "request exposed {forbidden}");
9549            }
9550            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
9551            assert_eq!(decoded, request);
9552            HarnessOperatorEnvelopeV1 {
9553                build_stamp: BUILD_STAMP.to_string(),
9554                credential: credential.clone(),
9555                request,
9556            }.validate().unwrap();
9557        }
9558
9559        assert!(HarnessOperatorRequestV1::InspectNodeWorkspace {
9560            node_id: String::new(),
9561            workspace_id,
9562        }.validate().is_err());
9563        assert!(HarnessOperatorRequestV1::InspectNodeWorkspace {
9564            node_id,
9565            workspace_id: String::new(),
9566        }.validate().is_err());
9567    }
9568
9569    #[test]
9570    fn operator_v9_node_workspace_replies_round_trip_without_run_or_diagnostic_fields() {
9571        let origin = node_workspace_origin();
9572        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
9573        let commit = git_commit('a');
9574        let responses = vec![
9575            HarnessOperatorResponseV1::NodeWorkspaceInspected(
9576                HarnessNodeWorkspaceInspectionV1 {
9577                    origin: origin.clone(),
9578                    entries: vec![HarnessWorkspaceTreeEntryV1 {
9579                        relative_path: path.clone(),
9580                        kind: HarnessWorkspaceEntryKindV1::File,
9581                    }],
9582                    tree_truncated: false,
9583                    git: HarnessGitSummaryV1 {
9584                        is_repository: true,
9585                        branch: Some("main".to_owned()),
9586                        status: vec![HarnessGitStatusEntryV1 {
9587                            index_status: HarnessGitStatusCodeV1::Unmodified,
9588                            worktree_status: HarnessGitStatusCodeV1::Modified,
9589                            path: path.clone(),
9590                            previous_path: None,
9591                        }],
9592                        recent_commits: vec![HarnessGitCommitSummaryV1 {
9593                            id: commit.id.clone(),
9594                            summary: commit.subject.clone(),
9595                        }],
9596                        truncated: false,
9597                    },
9598                    truncation: None,
9599                },
9600            ),
9601            HarnessOperatorResponseV1::NodeWorkspaceFileRead(HarnessNodeWorkspaceFileV1 {
9602                origin: origin.clone(),
9603                path: path.clone(),
9604                content: HarnessWorkspaceFileContentV1::Utf8 {
9605                    text: "fn main() {}\n".to_owned(),
9606                    byte_len: 13,
9607                },
9608                revision: Some(HarnessWorkspaceFileRevisionV1::new("b".repeat(64)).unwrap()),
9609            }),
9610            HarnessOperatorResponseV1::NodeGitHistoryRead(HarnessNodeGitHistoryPageV1 {
9611                origin: origin.clone(),
9612                path: Some(path.clone()),
9613                commits: vec![commit],
9614                next_before: None,
9615                truncated: false,
9616            }),
9617            HarnessOperatorResponseV1::NodeGitDiffRead(HarnessNodeGitDiffV1 {
9618                origin,
9619                mode: HarnessGitDiffModeV1::Working,
9620                path: Some(path),
9621                text: "diff --git a/src/lib.rs b/src/lib.rs\n".to_owned(),
9622                truncated: false,
9623            }),
9624        ];
9625        for response in responses {
9626            response.validate().expect("valid V9 node-workspace response");
9627            let encoded = serde_json::to_string(&response).unwrap();
9628            assert_json_has_no_forbidden_keys(&encoded, &[
9629                "run_id",
9630                "run_revision",
9631                "root",
9632                "worktree",
9633                "endpoint",
9634                "diagnostic",
9635                "author_email",
9636                "committer_email",
9637                "environment",
9638            ]);
9639            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
9640            assert_eq!(decoded, response);
9641        }
9642
9643        let mismatched_workspace = HarnessNodeWorkspaceOriginV1 {
9644            workspace_id: "other-workspace".to_owned(),
9645            ..node_workspace_origin()
9646        };
9647        assert!(mismatched_workspace.validate_for("node-a", "workspace-a").is_err());
9648        mismatched_workspace.validate_for("node-a", "other-workspace").unwrap();
9649    }
9650
9651    #[test]
9652    fn operator_node_workspace_write_verbs_are_exact_round_trips() {
9653        let node_id = node_workspace_origin().node_id;
9654        let workspace_id = node_workspace_origin().workspace_id;
9655        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
9656        let revision = HarnessWorkspaceFileRevisionV1::new("b".repeat(64)).unwrap();
9657        let requests = vec![
9658            HarnessOperatorRequestV1::WriteNodeWorkspaceFile {
9659                node_id: node_id.clone(),
9660                workspace_id: workspace_id.clone(),
9661                path: path.clone(),
9662                content: "fn main() {}\n".to_owned(),
9663                expected_revision: revision.clone(),
9664            },
9665            HarnessOperatorRequestV1::CreateNodeWorkspaceFile {
9666                node_id: node_id.clone(),
9667                workspace_id: workspace_id.clone(),
9668                path: path.clone(),
9669            },
9670            HarnessOperatorRequestV1::CreateNodeWorkspaceDirectory {
9671                node_id: node_id.clone(),
9672                workspace_id: workspace_id.clone(),
9673                path: path.clone(),
9674            },
9675        ];
9676        let credential = HarnessOperatorCredential::parse(format!(
9677            "g4aho_{}",
9678            "a".repeat(64),
9679        )).unwrap();
9680        for request in requests {
9681            request.validate().expect("valid node-workspace write request");
9682            let encoded = serde_json::to_string(&request).unwrap();
9683            for forbidden in ["run_id", "endpoint", "root", "worktree", "environment"] {
9684                assert!(!encoded.contains(forbidden), "request exposed {forbidden}");
9685            }
9686            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
9687            assert_eq!(decoded, request);
9688            HarnessOperatorEnvelopeV1 {
9689                build_stamp: BUILD_STAMP.to_string(),
9690                credential: credential.clone(),
9691                request,
9692            }.validate().unwrap();
9693        }
9694
9695        let origin = node_workspace_origin();
9696        let responses = vec![
9697            HarnessOperatorResponseV1::NodeWorkspaceFileWritten(HarnessNodeWorkspaceFileV1 {
9698                origin: origin.clone(),
9699                path: path.clone(),
9700                content: HarnessWorkspaceFileContentV1::Utf8 {
9701                    text: "fn main() {}\n".to_owned(),
9702                    byte_len: 13,
9703                },
9704                revision: Some(revision.clone()),
9705            }),
9706            HarnessOperatorResponseV1::NodeWorkspaceFileCreated(HarnessNodeWorkspaceFileV1 {
9707                origin: origin.clone(),
9708                path: path.clone(),
9709                content: HarnessWorkspaceFileContentV1::Utf8 {
9710                    text: String::new(),
9711                    byte_len: 0,
9712                },
9713                revision: Some(revision.clone()),
9714            }),
9715            HarnessOperatorResponseV1::NodeWorkspaceDirectoryCreated(
9716                HarnessNodeWorkspaceDirectoryV1 {
9717                    origin: origin.clone(),
9718                    entry: HarnessWorkspaceTreeEntryV1 {
9719                        relative_path: path.clone(),
9720                        kind: HarnessWorkspaceEntryKindV1::Directory,
9721                    },
9722                },
9723            ),
9724        ];
9725        for response in responses {
9726            response.validate().expect("valid V11 node-workspace write response");
9727            let encoded = serde_json::to_string(&response).unwrap();
9728            assert_json_has_no_forbidden_keys(&encoded, &[
9729                "run_id",
9730                "run_revision",
9731                "root",
9732                "worktree",
9733                "endpoint",
9734                "diagnostic",
9735                "environment",
9736            ]);
9737            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
9738            assert_eq!(decoded, response);
9739        }
9740
9741        let wrong_kind_directory = HarnessNodeWorkspaceDirectoryV1 {
9742            origin,
9743            entry: HarnessWorkspaceTreeEntryV1 {
9744                relative_path: path,
9745                kind: HarnessWorkspaceEntryKindV1::File,
9746            },
9747        };
9748        assert!(wrong_kind_directory.validate().is_err());
9749    }
9750
9751    #[test]
9752    fn node_workspace_write_v11_requests_reject_malformed_fields() {
9753        let node_id = node_workspace_origin().node_id;
9754        let workspace_id = node_workspace_origin().workspace_id;
9755        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
9756        let revision = HarnessWorkspaceFileRevisionV1::new("b".repeat(64)).unwrap();
9757
9758        let oversized_content = HarnessOperatorRequestV1::WriteNodeWorkspaceFile {
9759            node_id: node_id.clone(),
9760            workspace_id: workspace_id.clone(),
9761            path: path.clone(),
9762            content: "x".repeat(HARNESS_WORKSPACE_FILE_MAX_BYTES + 1),
9763            expected_revision: revision.clone(),
9764        };
9765        assert!(matches!(
9766            oversized_content.validate(),
9767            Err(HarnessOperatorApiError::InvalidWorkspaceFile),
9768        ));
9769        let bounded_content = HarnessOperatorRequestV1::WriteNodeWorkspaceFile {
9770            node_id: node_id.clone(),
9771            workspace_id: workspace_id.clone(),
9772            path: path.clone(),
9773            content: "x".repeat(HARNESS_WORKSPACE_FILE_MAX_BYTES),
9774            expected_revision: revision.clone(),
9775        };
9776        bounded_content.validate().unwrap();
9777
9778        assert!(HarnessWorkspaceFileRevisionV1::new("not-a-sha256-digest").is_err());
9779        assert!(HarnessWorkspaceFileRevisionV1::new("A".repeat(64)).is_err());
9780
9781        let empty_node_write = HarnessOperatorRequestV1::WriteNodeWorkspaceFile {
9782            node_id: String::new(),
9783            workspace_id: workspace_id.clone(),
9784            path: path.clone(),
9785            content: "ok".to_owned(),
9786            expected_revision: revision,
9787        };
9788        assert!(empty_node_write.validate().is_err());
9789
9790        let empty_workspace_create_file = HarnessOperatorRequestV1::CreateNodeWorkspaceFile {
9791            node_id: node_id.clone(),
9792            workspace_id: String::new(),
9793            path: path.clone(),
9794        };
9795        assert!(empty_workspace_create_file.validate().is_err());
9796
9797        let empty_node_create_directory = HarnessOperatorRequestV1::CreateNodeWorkspaceDirectory {
9798            node_id: String::new(),
9799            workspace_id,
9800            path,
9801        };
9802        assert!(empty_node_create_directory.validate().is_err());
9803    }
9804
9805    #[test]
9806    fn operator_context_source_observation_is_exact_and_private() {
9807        let run_id = HarnessRunId::new(format!("hrun_{}", "8".repeat(24))).unwrap();
9808        let request = HarnessOperatorRequestV1::ObserveRunContextSource {
9809            run_id: run_id.clone(),
9810        };
9811        request.validate().unwrap();
9812        let request_json = serde_json::to_value(&request).unwrap();
9813        assert_eq!(
9814            request_json,
9815            serde_json::json!({
9816                "kind": "observe-run-context-source",
9817                "run_id": run_id,
9818            }),
9819        );
9820        for forbidden in ["authority", "operation_id", "idempotency_ref", "task_id"] {
9821            assert!(!request_json.to_string().contains(forbidden), "leaked {forbidden}");
9822        }
9823        let credential = HarnessOperatorCredential::parse(format!(
9824            "g4aho_{}",
9825            "a".repeat(64),
9826        )).unwrap();
9827        HarnessOperatorEnvelopeV1 {
9828            build_stamp: BUILD_STAMP.to_string(),
9829            credential,
9830            request,
9831        }.validate().unwrap();
9832
9833        let response = HarnessRunContextSourceObservationV1 {
9834            run_id: HarnessRunId::new(format!("hrun_{}", "8".repeat(24))).unwrap(),
9835            run_revision: HarnessRevision::new(9).unwrap(),
9836            feature_state: FeatureObservationStateV1::Observed,
9837            message_count: 17,
9838            message_count_exact: true,
9839            completed_turn_count: Some(6),
9840            total_tokens: Some(4_096),
9841            observed_at_unix_ms: Some(1_000),
9842        };
9843        response.validate().unwrap();
9844        let response_json = serde_json::to_value(
9845            HarnessOperatorResponseV1::RunContextSourceObserved(response),
9846        ).unwrap();
9847        assert_eq!(
9848            response_json,
9849            serde_json::json!({
9850                "kind": "run-context-source-observed",
9851                "value": {
9852                    "run_id": format!("hrun_{}", "8".repeat(24)),
9853                    "run_revision": 9,
9854                    "feature_state": "observed",
9855                    "message_count": 17,
9856                    "message_count_exact": true,
9857                    "completed_turn_count": 6,
9858                    "total_tokens": 4096,
9859                    "observed_at_unix_ms": 1000,
9860                }
9861            }),
9862        );
9863        let response_json = response_json.to_string();
9864        for forbidden in [
9865            "transcript",
9866            "message_text",
9867            "provider_session",
9868            "provider_profile",
9869            "provider_home",
9870            "credential",
9871            "auth",
9872            "path",
9873            "model",
9874        ] {
9875            assert!(!response_json.contains(forbidden), "leaked {forbidden}");
9876        }
9877
9878        let mut unknown = request_json;
9879        unknown["provider_session_id"] = serde_json::json!("forbidden");
9880        assert!(serde_json::from_value::<HarnessOperatorRequestV1>(unknown).is_err());
9881    }
9882
9883    #[test]
9884    fn context_source_observation_rejects_inexact_or_mismatched_eligibility_claims() {
9885        let run_id = HarnessRunId::new(format!("hrun_{}", "8".repeat(24))).unwrap();
9886        let observed = HarnessRunContextSourceObservationV1 {
9887            run_id: run_id.clone(),
9888            run_revision: HarnessRevision::new(9).unwrap(),
9889            feature_state: FeatureObservationStateV1::Observed,
9890            message_count: 17,
9891            message_count_exact: true,
9892            completed_turn_count: Some(6),
9893            total_tokens: Some(4_096),
9894            observed_at_unix_ms: Some(1_000),
9895        };
9896        observed.validate_for(&run_id).unwrap();
9897
9898        let mut inexact = observed.clone();
9899        inexact.message_count_exact = false;
9900        assert!(inexact.validate().is_err());
9901        let mut empty = observed.clone();
9902        empty.message_count = 0;
9903        assert!(empty.validate().is_err());
9904        let mut impossible_turns = observed.clone();
9905        impossible_turns.completed_turn_count = Some(18);
9906        assert!(impossible_turns.validate().is_err());
9907        let unobserved = HarnessRunContextSourceObservationV1 {
9908            feature_state: FeatureObservationStateV1::SupportedNotObserved,
9909            message_count: 0,
9910            message_count_exact: false,
9911            completed_turn_count: None,
9912            total_tokens: None,
9913            observed_at_unix_ms: None,
9914            ..observed.clone()
9915        };
9916        unobserved.validate().unwrap();
9917        let mut leaked_count = unobserved;
9918        leaked_count.total_tokens = Some(1);
9919        assert!(leaked_count.validate().is_err());
9920
9921        let other_run_id = HarnessRunId::new(format!("hrun_{}", "9".repeat(24))).unwrap();
9922        assert!(matches!(
9923            observed.validate_for(&other_run_id),
9924            Err(HarnessOperatorApiError::InvalidRunContextSourceObservation),
9925        ));
9926    }
9927
9928    fn session_address(instance_id: u64, generation: u64) -> HarnessRuntimeSessionAddressV1 {
9929        HarnessRuntimeSessionAddressV1 {
9930            node_id: "node-a".to_owned(),
9931            incarnation_id: "07".repeat(16),
9932            workspace_id: "workspace-a".to_owned(),
9933            instance_id,
9934            generation,
9935        }
9936    }
9937
9938    fn sample_agent_stream_chunk(source_sequence: u64) -> HarnessAgentStreamChunkV1 {
9939        HarnessAgentStreamChunkV1 {
9940            source_sequence,
9941            kind: HarnessAgentStreamChunkKindV1::Text {
9942                text: format!("chunk-{source_sequence}"),
9943                is_delta: true,
9944            },
9945        }
9946    }
9947
9948    fn sample_terminal_frame(sequence: u64) -> HarnessRuntimeTerminalFrameV1 {
9949        HarnessRuntimeTerminalFrameV1 {
9950            sequence,
9951            size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 },
9952            cursor_row: 0,
9953            cursor_column: 0,
9954            formatted: format!("frame-{sequence}").into_bytes(),
9955            scrollback_formatted: Vec::new(),
9956            alternate_screen: false,
9957            mouse_protocol_enabled: false,
9958            mouse_protocol_encoding: HarnessRuntimeMouseProtocolEncodingV1::Default,
9959            produced_at_unix_ms: 1_000,
9960            screen_state: None,
9961            bracketed_paste: None,
9962        }
9963    }
9964
9965    fn sample_runtime_session(instance_id: u64, generation: u64) -> HarnessRuntimeSessionV1 {
9966        HarnessRuntimeSessionV1 {
9967            instance_id,
9968            generation,
9969            provider: "codex".to_owned(),
9970            transport: HarnessRuntimeTransportV1::Pty,
9971            status: HarnessRuntimeSessionStatusV1::Running,
9972            process_id: Some(1234),
9973            terminal_size: Some(HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 }),
9974            operation_pending: false,
9975            input_pending: false,
9976            screen_state: None,
9977        }
9978    }
9979
9980    /// A fully-populated `OperatorGateStateV1` -- one accepted option, one
9981    /// declined, matching the shape `gate4agent-shell-native`'s numbered-list
9982    /// parser actually produces (see that crate's own tests for the parser
9983    /// itself; this crate only owns the wire mirror and its bounds).
9984    fn sample_operator_gate_state() -> OperatorGateStateV1 {
9985        OperatorGateStateV1 {
9986            kind: OperatorGateKindV1::HookTrust,
9987            subject: OperatorGateSubjectV1::Hooks { count: Some(6) },
9988            input: OperatorGateInputV1::NumberedList,
9989            options: vec![
9990                OperatorGateOptionV1 {
9991                    text: "Trust all and continue".to_owned(),
9992                    semantics: OperatorGateOptionSemanticsV1::Accept,
9993                    selected: false,
9994                },
9995                OperatorGateOptionV1 {
9996                    text: "Continue without trusting".to_owned(),
9997                    semantics: OperatorGateOptionSemanticsV1::Decline,
9998                    selected: true,
9999                },
10000            ],
10001        }
10002    }
10003
10004    /// Every `PtyScreenStateV1` variant, for the round-trip and validation
10005    /// sweeps below -- kept as one list so a variant added to the enum
10006    /// without a matching addition here is caught by a stale sweep rather
10007    /// than silently under-covered.
10008    fn every_screen_state_variant() -> Vec<PtyScreenStateV1> {
10009        vec![
10010            PtyScreenStateV1::Unknown,
10011            PtyScreenStateV1::NotAgent { observed_process: "installer.exe".to_owned() },
10012            PtyScreenStateV1::OperatorGate { gate: sample_operator_gate_state() },
10013            PtyScreenStateV1::Failing { reason: "crash-loop".to_owned() },
10014            PtyScreenStateV1::Ready,
10015        ]
10016    }
10017
10018    /// The literal proof the `deny_unknown_fields` hazard is closed: a
10019    /// pre-V13 reply (`screen_state: None`) must omit the KEY entirely, not
10020    /// serialize it as `null` -- an older client's struct has no such field
10021    /// at all, so a `null` value would still trip `deny_unknown_fields`.
10022    #[test]
10023    fn a_terminal_frame_with_no_screen_state_serializes_without_the_key_at_all() {
10024        let frame = sample_terminal_frame(1);
10025        assert!(frame.screen_state.is_none());
10026        let value = serde_json::to_value(&frame).unwrap();
10027        assert!(
10028            value.as_object().unwrap().get("screen_state").is_none(),
10029            "expected no screen_state key in {value}",
10030        );
10031    }
10032
10033    /// Sibling of the above for `HarnessRuntimeSessionV1` -- the session
10034    /// inventory's own reply carries the identical hazard and the identical
10035    /// fix.
10036    #[test]
10037    fn a_runtime_session_with_no_screen_state_serializes_without_the_key_at_all() {
10038        let session = sample_runtime_session(1, 1);
10039        assert!(session.screen_state.is_none());
10040        let value = serde_json::to_value(&session).unwrap();
10041        assert!(
10042            value.as_object().unwrap().get("screen_state").is_none(),
10043            "expected no screen_state key in {value}",
10044        );
10045    }
10046
10047    /// `Some(..)` round-trips byte-exact for every variant, on both wire
10048    /// types that carry it.
10049    #[test]
10050    fn some_screen_state_round_trips_exactly_for_every_variant_on_both_carriers() {
10051        for variant in every_screen_state_variant() {
10052            let mut frame = sample_terminal_frame(1);
10053            frame.screen_state = Some(variant.clone());
10054            let decoded_frame: HarnessRuntimeTerminalFrameV1 = serde_json::from_slice(
10055                &serde_json::to_vec(&frame).unwrap(),
10056            ).unwrap();
10057            assert_eq!(decoded_frame, frame);
10058
10059            let mut session = sample_runtime_session(1, 1);
10060            session.screen_state = Some(variant.clone());
10061            let decoded_session: HarnessRuntimeSessionV1 = serde_json::from_slice(
10062                &serde_json::to_vec(&session).unwrap(),
10063            ).unwrap();
10064            assert_eq!(decoded_session, session);
10065        }
10066    }
10067
10068    /// `validate()` rejects an oversized carried string inside a present
10069    /// `screen_state`, on both carriers -- the bound is enforced regardless
10070    /// of which wire type happens to hold the value.
10071    #[test]
10072    fn validate_rejects_an_oversized_string_inside_a_present_screen_state() {
10073        let oversized_process = "x".repeat(HARNESS_SCREEN_STATE_PROCESS_MAX_BYTES + 1);
10074        let oversized_gate = "x".repeat(HARNESS_SCREEN_STATE_GATE_MAX_BYTES + 1);
10075        let cases = [
10076            PtyScreenStateV1::NotAgent { observed_process: oversized_process },
10077            PtyScreenStateV1::Failing { reason: oversized_gate },
10078        ];
10079        for case in cases {
10080            let mut frame = sample_terminal_frame(1);
10081            frame.screen_state = Some(case.clone());
10082            assert!(
10083                matches!(frame.validate(), Err(HarnessOperatorApiError::InvalidRuntimeInventory)),
10084                "expected frame validate() to reject {case:?}",
10085            );
10086
10087            let mut session = sample_runtime_session(1, 1);
10088            session.screen_state = Some(case.clone());
10089            assert!(
10090                matches!(session.validate(), Err(HarnessOperatorApiError::InvalidRuntimeInventory)),
10091                "expected session validate() to reject {case:?}",
10092            );
10093        }
10094    }
10095
10096    /// Sibling of the sweep above for `OperatorGateStateV1`'s own nested
10097    /// bounds -- an oversized `Directory` path and an oversized option
10098    /// label are each, on their own, enough to fail `validate()`, on both
10099    /// carriers.
10100    #[test]
10101    fn validate_rejects_an_oversized_field_nested_inside_an_operator_gate() {
10102        let oversized_path = "x".repeat(HARNESS_GATE_PATH_MAX_BYTES + 1);
10103        let oversized_option_text = "x".repeat(HARNESS_GATE_OPTION_TEXT_MAX_BYTES + 1);
10104        let cases = [
10105            PtyScreenStateV1::OperatorGate {
10106                gate: OperatorGateStateV1 {
10107                    subject: OperatorGateSubjectV1::Directory { path: Some(oversized_path) },
10108                    ..sample_operator_gate_state()
10109                },
10110            },
10111            PtyScreenStateV1::OperatorGate {
10112                gate: OperatorGateStateV1 {
10113                    options: vec![OperatorGateOptionV1 {
10114                        text: oversized_option_text,
10115                        semantics: OperatorGateOptionSemanticsV1::Accept,
10116                        selected: false,
10117                    }],
10118                    ..sample_operator_gate_state()
10119                },
10120            },
10121        ];
10122        for case in cases {
10123            let mut frame = sample_terminal_frame(1);
10124            frame.screen_state = Some(case.clone());
10125            assert!(
10126                matches!(frame.validate(), Err(HarnessOperatorApiError::InvalidRuntimeInventory)),
10127                "expected frame validate() to reject {case:?}",
10128            );
10129
10130            let mut session = sample_runtime_session(1, 1);
10131            session.screen_state = Some(case.clone());
10132            assert!(
10133                matches!(session.validate(), Err(HarnessOperatorApiError::InvalidRuntimeInventory)),
10134                "expected session validate() to reject {case:?}",
10135            );
10136        }
10137    }
10138
10139    #[test]
10140    fn operator_session_verbs_are_exact_round_trips() {
10141        let session = session_address(41, 3);
10142        let requests = vec![
10143            HarnessOperatorRequestV1::SpawnSession {
10144                node_id: "node-a".to_owned(),
10145                workspace_id: "workspace-a".to_owned(),
10146                provider: "claude".to_owned(),
10147                provider_profile: "claude-default".to_owned(),
10148                mode: HarnessExecutionModeV1::Pty,
10149                terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 40, columns: 120 },
10150                approval_level: None,
10151            },
10152            HarnessOperatorRequestV1::WriteSessionInput {
10153                session: session.clone(),
10154                text: "echo hi\n".to_owned(),
10155            },
10156            HarnessOperatorRequestV1::ResizeSession {
10157                session: session.clone(),
10158                terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 50, columns: 160 },
10159            },
10160            HarnessOperatorRequestV1::StopSession {
10161                session: session.clone(),
10162                force: true,
10163            },
10164        ];
10165        let credential = HarnessOperatorCredential::parse(format!(
10166            "g4aho_{}",
10167            "a".repeat(64),
10168        )).unwrap();
10169        for request in requests {
10170            request.validate().expect("valid session verb request");
10171            let encoded = serde_json::to_string(&request).unwrap();
10172            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10173            assert_eq!(decoded, request);
10174            HarnessOperatorEnvelopeV1 {
10175                build_stamp: BUILD_STAMP.to_string(),
10176                credential: credential.clone(),
10177                request,
10178            }.validate().unwrap();
10179        }
10180
10181        let responses = vec![
10182            HarnessOperatorResponseV1::SessionSpawned(session.clone()),
10183            HarnessOperatorResponseV1::SessionInputWritten,
10184            HarnessOperatorResponseV1::SessionResized,
10185            HarnessOperatorResponseV1::SessionStopped,
10186        ];
10187        for response in responses {
10188            response.validate().expect("valid V10 session verb response");
10189            let encoded = serde_json::to_string(&response).unwrap();
10190            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10191            assert_eq!(decoded, response);
10192        }
10193    }
10194
10195    /// `PromptSession`'s own round trip, kept separate from the V10 session-
10196    /// verb test above: it landed on the wire under a later version and
10197    /// carries its own PTY-refusal doctrine (see its doc comment), not just
10198    /// another `WriteSessionInput`-shaped verb.
10199    #[test]
10200    fn operator_prompt_session_is_exact_round_trip() {
10201        let session = session_address(41, 3);
10202        let request = HarnessOperatorRequestV1::PromptSession {
10203            session: session.clone(),
10204            text: "please continue".to_owned(),
10205        };
10206        request.validate().expect("valid prompt-session request");
10207        let encoded = serde_json::to_string(&request).unwrap();
10208        let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10209        assert_eq!(decoded, request);
10210        let credential = HarnessOperatorCredential::parse(format!(
10211            "g4aho_{}",
10212            "a".repeat(64),
10213        )).unwrap();
10214        HarnessOperatorEnvelopeV1 {
10215            build_stamp: BUILD_STAMP.to_string(),
10216            credential,
10217            request,
10218        }.validate().unwrap();
10219
10220        let response = HarnessOperatorResponseV1::SessionPrompted;
10221        response.validate().expect("valid prompt-session response");
10222        let encoded = serde_json::to_string(&response).unwrap();
10223        let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10224        assert_eq!(decoded, response);
10225    }
10226
10227    #[test]
10228    fn session_spawn_and_control_requests_reject_malformed_fields() {
10229        let session = session_address(41, 3);
10230        let valid_spawn = HarnessOperatorRequestV1::SpawnSession {
10231            node_id: "node-a".to_owned(),
10232            workspace_id: "workspace-a".to_owned(),
10233            provider: "claude".to_owned(),
10234            provider_profile: "claude-default".to_owned(),
10235            mode: HarnessExecutionModeV1::Pty,
10236            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 40, columns: 120 },
10237            approval_level: None,
10238        };
10239        valid_spawn.validate().unwrap();
10240
10241        let mut empty_node = valid_spawn.clone();
10242        if let HarnessOperatorRequestV1::SpawnSession { node_id, .. } = &mut empty_node {
10243            *node_id = String::new();
10244        }
10245        assert!(matches!(
10246            empty_node.validate(),
10247            Err(HarnessOperatorApiError::InvalidWorkspaceOrigin),
10248        ));
10249
10250        let mut bad_provider = valid_spawn.clone();
10251        if let HarnessOperatorRequestV1::SpawnSession { provider, .. } = &mut bad_provider {
10252            *provider = "not a provider id!".to_owned();
10253        }
10254        assert!(matches!(
10255            bad_provider.validate(),
10256            Err(HarnessOperatorApiError::InvalidSessionSpawn),
10257        ));
10258
10259        let mut zero_rows = valid_spawn.clone();
10260        if let HarnessOperatorRequestV1::SpawnSession { terminal_size, .. } = &mut zero_rows {
10261            terminal_size.rows = 0;
10262        }
10263        assert!(matches!(
10264            zero_rows.validate(),
10265            Err(HarnessOperatorApiError::InvalidSessionSpawn),
10266        ));
10267
10268        let oversized_input = HarnessOperatorRequestV1::WriteSessionInput {
10269            session: session.clone(),
10270            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES + 1),
10271        };
10272        assert!(matches!(
10273            oversized_input.validate(),
10274            Err(HarnessOperatorApiError::InvalidSessionControl),
10275        ));
10276        let bounded_input = HarnessOperatorRequestV1::WriteSessionInput {
10277            session: session.clone(),
10278            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES),
10279        };
10280        bounded_input.validate().unwrap();
10281
10282        let oversized_prompt = HarnessOperatorRequestV1::PromptSession {
10283            session: session.clone(),
10284            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES + 1),
10285        };
10286        assert!(matches!(
10287            oversized_prompt.validate(),
10288            Err(HarnessOperatorApiError::InvalidSessionControl),
10289        ));
10290        let bounded_prompt = HarnessOperatorRequestV1::PromptSession {
10291            session: session.clone(),
10292            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES),
10293        };
10294        bounded_prompt.validate().unwrap();
10295
10296        let zero_resize = HarnessOperatorRequestV1::ResizeSession {
10297            session: session.clone(),
10298            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 0 },
10299        };
10300        assert!(matches!(
10301            zero_resize.validate(),
10302            Err(HarnessOperatorApiError::InvalidSessionControl),
10303        ));
10304
10305        let mut malformed_session = session.clone();
10306        malformed_session.instance_id = 0;
10307        let bad_stop = HarnessOperatorRequestV1::StopSession {
10308            session: malformed_session,
10309            force: false,
10310        };
10311        assert!(bad_stop.validate().is_err());
10312    }
10313
10314    #[test]
10315    fn operator_session_control_verbs_are_exact_round_trips() {
10316        let session = session_address(41, 3);
10317        let requests = vec![
10318            HarnessOperatorRequestV1::ControlSession {
10319                session: session.clone(),
10320                control: HarnessTerminalControlV1::Enter,
10321            },
10322            HarnessOperatorRequestV1::WriteSessionBytes {
10323                session: session.clone(),
10324                bytes: vec![0x1b, b'[', b'A'],
10325            },
10326            HarnessOperatorRequestV1::PasteSession {
10327                session: session.clone(),
10328                text: "pasted text\n".to_owned(),
10329            },
10330            HarnessOperatorRequestV1::RemoveSession {
10331                session: session.clone(),
10332            },
10333            HarnessOperatorRequestV1::ResumeSession {
10334                session: session.clone(),
10335                terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 },
10336            },
10337        ];
10338        let credential = HarnessOperatorCredential::parse(format!(
10339            "g4aho_{}",
10340            "a".repeat(64),
10341        )).unwrap();
10342        for request in requests {
10343            request.validate().expect("valid session control verb request");
10344            let encoded = serde_json::to_string(&request).unwrap();
10345            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10346            assert_eq!(decoded, request);
10347            HarnessOperatorEnvelopeV1 {
10348                build_stamp: BUILD_STAMP.to_string(),
10349                credential: credential.clone(),
10350                request,
10351            }.validate().unwrap();
10352        }
10353
10354        let responses = vec![
10355            HarnessOperatorResponseV1::SessionControlled,
10356            HarnessOperatorResponseV1::SessionBytesWritten,
10357            HarnessOperatorResponseV1::SessionPasted,
10358            HarnessOperatorResponseV1::SessionRemoved,
10359            HarnessOperatorResponseV1::SessionResumed,
10360        ];
10361        for response in responses {
10362            response.validate().expect("valid V11 session control verb response");
10363            let encoded = serde_json::to_string(&response).unwrap();
10364            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10365            assert_eq!(decoded, response);
10366        }
10367    }
10368
10369    #[test]
10370    fn session_control_v11_requests_reject_malformed_fields() {
10371        let session = session_address(41, 3);
10372
10373        let oversized_bytes = HarnessOperatorRequestV1::WriteSessionBytes {
10374            session: session.clone(),
10375            bytes: vec![0u8; HARNESS_SESSION_BYTES_MAX_BYTES + 1],
10376        };
10377        assert!(matches!(
10378            oversized_bytes.validate(),
10379            Err(HarnessOperatorApiError::InvalidSessionControl),
10380        ));
10381        let empty_bytes = HarnessOperatorRequestV1::WriteSessionBytes {
10382            session: session.clone(),
10383            bytes: Vec::new(),
10384        };
10385        assert!(matches!(
10386            empty_bytes.validate(),
10387            Err(HarnessOperatorApiError::InvalidSessionControl),
10388        ));
10389        let bounded_bytes = HarnessOperatorRequestV1::WriteSessionBytes {
10390            session: session.clone(),
10391            bytes: vec![0u8; HARNESS_SESSION_BYTES_MAX_BYTES],
10392        };
10393        bounded_bytes.validate().unwrap();
10394
10395        let oversized_paste = HarnessOperatorRequestV1::PasteSession {
10396            session: session.clone(),
10397            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES + 1),
10398        };
10399        assert!(matches!(
10400            oversized_paste.validate(),
10401            Err(HarnessOperatorApiError::InvalidSessionControl),
10402        ));
10403        let bounded_paste = HarnessOperatorRequestV1::PasteSession {
10404            session: session.clone(),
10405            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES),
10406        };
10407        bounded_paste.validate().unwrap();
10408
10409        let zero_resume_size = HarnessOperatorRequestV1::ResumeSession {
10410            session: session.clone(),
10411            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 0, columns: 80 },
10412        };
10413        assert!(matches!(
10414            zero_resume_size.validate(),
10415            Err(HarnessOperatorApiError::InvalidSessionControl),
10416        ));
10417
10418        let mut malformed_session = session.clone();
10419        malformed_session.instance_id = 0;
10420        let bad_remove = HarnessOperatorRequestV1::RemoveSession {
10421            session: malformed_session.clone(),
10422        };
10423        assert!(bad_remove.validate().is_err());
10424        let bad_control = HarnessOperatorRequestV1::ControlSession {
10425            session: malformed_session,
10426            control: HarnessTerminalControlV1::Interrupt,
10427        };
10428        assert!(bad_control.validate().is_err());
10429    }
10430
10431    fn sample_native_session_selection() -> HarnessNativeSessionSelectionV1 {
10432        HarnessNativeSessionSelectionV1 {
10433            route: HarnessNativeSessionRouteV1 {
10434                node_id: "node-a".to_owned(),
10435                incarnation_id: "1".repeat(32),
10436                scope: HarnessNativeSessionCatalogScopeV1::Workspace,
10437                workspace_id: Some("workspace-a".to_owned()),
10438                provider: "codex".to_owned(),
10439            },
10440            catalog_revision: 7,
10441            recent_cutoff_unix_ms: 9,
10442            selection_id: "selection-a".to_owned(),
10443        }
10444    }
10445
10446    fn sample_managed_session(record_id: &str) -> HarnessRuntimeManagedSessionV1 {
10447        HarnessRuntimeManagedSessionV1 {
10448            record_id: record_id.to_owned(),
10449            display_name: "Session display name".to_owned(),
10450            display_name_truncated: false,
10451            provider: "codex".to_owned(),
10452            mode: HarnessRuntimeManagedModeV1::Pty,
10453            state: HarnessRuntimeManagedStateV1::Dormant,
10454            workspace_id: "workspace-a".to_owned(),
10455            active_binding: None,
10456            provider_identity_present: true,
10457            updated_at_unix_ms: 10,
10458            blocked_count: 0,
10459            last_blocked_at_ms: None,
10460        }
10461    }
10462
10463    #[test]
10464    fn operator_session_record_family_requests_are_exact_round_trips() {
10465        let requests = vec![
10466            HarnessOperatorRequestV1::PreviewSessionRecord {
10467                node_id: "node-a".to_owned(),
10468                record_id: "record-a".to_owned(),
10469                message_limit: HARNESS_NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX,
10470            },
10471            HarnessOperatorRequestV1::ResumeSessionRecord {
10472                node_id: "node-a".to_owned(),
10473                record_id: "record-a".to_owned(),
10474                terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 },
10475                initial_prompt: Some("continue".to_owned()),
10476            },
10477            HarnessOperatorRequestV1::RenameSessionRecord {
10478                node_id: "node-a".to_owned(),
10479                record_id: "record-a".to_owned(),
10480                display_name: "Renamed session".to_owned(),
10481            },
10482            HarnessOperatorRequestV1::SetSessionTask {
10483                node_id: "node-a".to_owned(),
10484                record_id: "record-a".to_owned(),
10485                expected_revision: 3,
10486                target: HarnessSessionTaskTargetV1::Existing {
10487                    task_id: format!("task-{}", "a".repeat(24)),
10488                },
10489            },
10490            HarnessOperatorRequestV1::ForgetSessionRecord {
10491                node_id: "node-a".to_owned(),
10492                record_id: "record-a".to_owned(),
10493            },
10494            HarnessOperatorRequestV1::IndexProviderSession {
10495                node_id: "node-a".to_owned(),
10496                workspace_id: "workspace-a".to_owned(),
10497                provider: "codex".to_owned(),
10498                identity: HarnessProviderSessionIdentityV1 {
10499                    key: HarnessProviderSessionKeyV1::SessionId,
10500                    id: "session-123".to_owned(),
10501                    transcript_path: Some("/tmp/transcript.json".to_owned()),
10502                },
10503                display_name: "Indexed session".to_owned(),
10504            },
10505            HarnessOperatorRequestV1::IndexNativeSession {
10506                selection: sample_native_session_selection(),
10507                display_name: "Native indexed".to_owned(),
10508            },
10509        ];
10510        let credential = HarnessOperatorCredential::parse(format!(
10511            "g4aho_{}",
10512            "a".repeat(64),
10513        )).unwrap();
10514        for request in requests {
10515            request.validate().expect("valid session-record family request");
10516            let encoded = serde_json::to_string(&request).unwrap();
10517            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10518            assert_eq!(decoded, request);
10519            HarnessOperatorEnvelopeV1 {
10520                build_stamp: BUILD_STAMP.to_string(),
10521                credential: credential.clone(),
10522                request,
10523            }.validate().unwrap();
10524        }
10525    }
10526
10527    #[test]
10528    fn operator_session_record_family_responses_are_exact_round_trips() {
10529        let record = sample_managed_session("record-a");
10530        let responses = vec![
10531            HarnessOperatorResponseV1::SessionRecordPreviewed(HarnessSessionRecordPreviewedV1 {
10532                record_id: "record-a".to_owned(),
10533                preview: HarnessNativeSessionPreviewV1 {
10534                    title: Some("Preview title".to_owned()),
10535                    modified_at_unix_ms: Some(10),
10536                    model: Some("gpt".to_owned()),
10537                    message_count: 1,
10538                    message_count_exact: true,
10539                    completed_turn_count: Some(1),
10540                    total_tokens: Some(100),
10541                    truncated: false,
10542                    messages: vec![HarnessNativeSessionPreviewMessageV1 {
10543                        role: HarnessNativeSessionPreviewRoleV1::User,
10544                        text: "hello".to_owned(),
10545                    }],
10546                },
10547            }),
10548            HarnessOperatorResponseV1::SessionRecordResumed(HarnessSessionRecordResumedV1 {
10549                record: record.clone(),
10550                session: session_address(41, 3),
10551            }),
10552            HarnessOperatorResponseV1::SessionRecordUpdated(record.clone()),
10553            HarnessOperatorResponseV1::SessionRecordForgotten { record_id: "record-a".to_owned() },
10554            HarnessOperatorResponseV1::ProviderSessionIndexed(record.clone()),
10555            HarnessOperatorResponseV1::NativeSessionIndexed(HarnessNativeSessionIndexedV1 {
10556                selection: sample_native_session_selection(),
10557                record,
10558            }),
10559        ];
10560        for response in responses {
10561            response.validate().expect("valid session-record family response");
10562            let encoded = serde_json::to_string(&response).unwrap();
10563            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10564            assert_eq!(decoded, response);
10565        }
10566    }
10567
10568    #[test]
10569    fn session_record_family_requests_reject_malformed_fields() {
10570        let bad_node_id = HarnessOperatorRequestV1::PreviewSessionRecord {
10571            node_id: String::new(),
10572            record_id: "record-a".to_owned(),
10573            message_limit: 1,
10574        };
10575        assert!(matches!(
10576            bad_node_id.validate(),
10577            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10578        ));
10579
10580        let bad_limit = HarnessOperatorRequestV1::PreviewSessionRecord {
10581            node_id: "node-a".to_owned(),
10582            record_id: "record-a".to_owned(),
10583            message_limit: 0,
10584        };
10585        assert!(matches!(bad_limit.validate(), Err(HarnessOperatorApiError::InvalidLimit)));
10586
10587        let empty_display_name = HarnessOperatorRequestV1::RenameSessionRecord {
10588            node_id: "node-a".to_owned(),
10589            record_id: "record-a".to_owned(),
10590            display_name: String::new(),
10591        };
10592        assert!(matches!(
10593            empty_display_name.validate(),
10594            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10595        ));
10596
10597        let oversized_display_name = HarnessOperatorRequestV1::RenameSessionRecord {
10598            node_id: "node-a".to_owned(),
10599            record_id: "record-a".to_owned(),
10600            display_name: "x".repeat(HARNESS_SESSION_RECORD_DISPLAY_NAME_MAX_BYTES + 1),
10601        };
10602        assert!(matches!(
10603            oversized_display_name.validate(),
10604            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10605        ));
10606
10607        let bounded_display_name = HarnessOperatorRequestV1::RenameSessionRecord {
10608            node_id: "node-a".to_owned(),
10609            record_id: "record-a".to_owned(),
10610            display_name: "x".repeat(HARNESS_SESSION_RECORD_DISPLAY_NAME_MAX_BYTES),
10611        };
10612        bounded_display_name.validate().unwrap();
10613
10614        let zero_resume_size = HarnessOperatorRequestV1::ResumeSessionRecord {
10615            node_id: "node-a".to_owned(),
10616            record_id: "record-a".to_owned(),
10617            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 0, columns: 80 },
10618            initial_prompt: None,
10619        };
10620        assert!(matches!(
10621            zero_resume_size.validate(),
10622            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10623        ));
10624
10625        let empty_initial_prompt = HarnessOperatorRequestV1::ResumeSessionRecord {
10626            node_id: "node-a".to_owned(),
10627            record_id: "record-a".to_owned(),
10628            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 },
10629            initial_prompt: Some(String::new()),
10630        };
10631        assert!(matches!(
10632            empty_initial_prompt.validate(),
10633            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10634        ));
10635
10636        let bad_task_id = HarnessOperatorRequestV1::SetSessionTask {
10637            node_id: "node-a".to_owned(),
10638            record_id: "record-a".to_owned(),
10639            expected_revision: 1,
10640            target: HarnessSessionTaskTargetV1::Existing { task_id: String::new() },
10641        };
10642        assert!(matches!(
10643            bad_task_id.validate(),
10644            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10645        ));
10646
10647        let valid_clear_task = HarnessOperatorRequestV1::SetSessionTask {
10648            node_id: "node-a".to_owned(),
10649            record_id: "record-a".to_owned(),
10650            expected_revision: 1,
10651            target: HarnessSessionTaskTargetV1::Clear,
10652        };
10653        valid_clear_task.validate().unwrap();
10654
10655        let bad_identity_id = HarnessOperatorRequestV1::IndexProviderSession {
10656            node_id: "node-a".to_owned(),
10657            workspace_id: "workspace-a".to_owned(),
10658            provider: "codex".to_owned(),
10659            identity: HarnessProviderSessionIdentityV1 {
10660                key: HarnessProviderSessionKeyV1::SessionId,
10661                id: String::new(),
10662                transcript_path: None,
10663            },
10664            display_name: "Indexed session".to_owned(),
10665        };
10666        assert!(matches!(
10667            bad_identity_id.validate(),
10668            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10669        ));
10670
10671        let mut malformed_selection = sample_native_session_selection();
10672        malformed_selection.catalog_revision = 0;
10673        let bad_index_native = HarnessOperatorRequestV1::IndexNativeSession {
10674            selection: malformed_selection.clone(),
10675            display_name: "Native indexed".to_owned(),
10676        };
10677        assert!(bad_index_native.validate().is_err());
10678        malformed_selection.catalog_revision = 7;
10679        HarnessOperatorRequestV1::IndexNativeSession {
10680            selection: malformed_selection,
10681            display_name: "Native indexed".to_owned(),
10682        }.validate().unwrap();
10683    }
10684
10685    #[test]
10686    fn session_record_family_responses_reject_malformed_fields() {
10687        let mut malformed_record = sample_managed_session("record-a");
10688        malformed_record.record_id = String::new();
10689        assert!(
10690            HarnessOperatorResponseV1::SessionRecordUpdated(malformed_record.clone())
10691                .validate()
10692                .is_err()
10693        );
10694        assert!(
10695            HarnessOperatorResponseV1::ProviderSessionIndexed(malformed_record.clone())
10696                .validate()
10697                .is_err()
10698        );
10699        assert!(
10700            HarnessOperatorResponseV1::SessionRecordResumed(HarnessSessionRecordResumedV1 {
10701                record: malformed_record,
10702                session: session_address(41, 3),
10703            }).validate().is_err()
10704        );
10705
10706        assert!(
10707            HarnessOperatorResponseV1::SessionRecordForgotten { record_id: String::new() }
10708                .validate()
10709                .is_err()
10710        );
10711
10712        let mut malformed_session = session_address(41, 3);
10713        malformed_session.instance_id = 0;
10714        assert!(
10715            HarnessOperatorResponseV1::SessionRecordResumed(HarnessSessionRecordResumedV1 {
10716                record: sample_managed_session("record-a"),
10717                session: malformed_session,
10718            }).validate().is_err()
10719        );
10720
10721        let mut malformed_selection = sample_native_session_selection();
10722        malformed_selection.selection_id = String::new();
10723        assert!(
10724            HarnessOperatorResponseV1::NativeSessionIndexed(HarnessNativeSessionIndexedV1 {
10725                selection: malformed_selection,
10726                record: sample_managed_session("record-a"),
10727            }).validate().is_err()
10728        );
10729    }
10730
10731    fn host_path(value: &str) -> HarnessHostPathV1 {
10732        HarnessHostPathV1::new(value).unwrap()
10733    }
10734
10735    fn sample_workspace_snapshot(workspace_id: &str) -> HarnessWorkspaceSnapshotV1 {
10736        HarnessWorkspaceSnapshotV1 {
10737            workspace_id: workspace_id.to_owned(),
10738            canonical_root: host_path(r"C:\fixtures\workspace"),
10739            worktree_service_mode: Some(HarnessWorktreeServiceModeV1::Manual),
10740        }
10741    }
10742
10743    fn sample_git_worktree_snapshot() -> HarnessGitWorktreeSnapshotV1 {
10744        HarnessGitWorktreeSnapshotV1 {
10745            path: host_path(r"C:\fixtures\worktree"),
10746            head: "a".repeat(40),
10747            branch: Some("feature/resource-mutation".to_owned()),
10748            is_bare: false,
10749            is_main: false,
10750            locked: false,
10751            prunable: false,
10752            workspace_id: Some("worktree-workspace".to_owned()),
10753        }
10754    }
10755
10756    #[test]
10757    fn operator_resource_mutation_family_requests_are_exact_round_trips() {
10758        let requests = vec![
10759            HarnessOperatorRequestV1::BrowseHostDirectories {
10760                node_id: "node-a".to_owned(),
10761                directory: Some(host_path(r"C:\fixtures")),
10762                after: Some(host_path(r"C:\fixtures\a")),
10763            },
10764            HarnessOperatorRequestV1::RegisterWorkspace {
10765                node_id: "node-a".to_owned(),
10766                workspace_id: "workspace-b".to_owned(),
10767                root: host_path(r"C:\fixtures\workspace-b"),
10768            },
10769            HarnessOperatorRequestV1::UnregisterWorkspace {
10770                node_id: "node-a".to_owned(),
10771                workspace_id: "workspace-b".to_owned(),
10772            },
10773            HarnessOperatorRequestV1::CreateStandaloneWorkspace {
10774                node_id: "node-a".to_owned(),
10775                workspace_id: "workspace-c".to_owned(),
10776                root: host_path(r"C:\fixtures\workspace-c"),
10777                initial_branch: Some("main".to_owned()),
10778            },
10779            HarnessOperatorRequestV1::CreateWorktree {
10780                node_id: "node-a".to_owned(),
10781                source_workspace_id: "workspace-a".to_owned(),
10782                workspace_id: "worktree-workspace".to_owned(),
10783                target_root: host_path(r"C:\fixtures\worktree"),
10784                branch: "feature/resource-mutation".to_owned(),
10785                base: Some("main".to_owned()),
10786            },
10787            HarnessOperatorRequestV1::RemoveWorktree {
10788                node_id: "node-a".to_owned(),
10789                source_workspace_id: "workspace-a".to_owned(),
10790                target_root: host_path(r"C:\fixtures\worktree"),
10791            },
10792            HarnessOperatorRequestV1::ExportContextPack { session: session_address(41, 3) },
10793            HarnessOperatorRequestV1::ForgetContextPack {
10794                node_id: "node-a".to_owned(),
10795                context_id: HarnessSelectorV1::new("context-a").unwrap(),
10796            },
10797        ];
10798        let credential = HarnessOperatorCredential::parse(format!(
10799            "g4aho_{}",
10800            "b".repeat(64),
10801        )).unwrap();
10802        for request in requests {
10803            request.validate().expect("valid resource-mutation family request");
10804            let encoded = serde_json::to_string(&request).unwrap();
10805            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10806            assert_eq!(decoded, request);
10807            HarnessOperatorEnvelopeV1 {
10808                build_stamp: BUILD_STAMP.to_string(),
10809                credential: credential.clone(),
10810                request,
10811            }.validate().unwrap();
10812        }
10813    }
10814
10815    #[test]
10816    fn operator_resource_mutation_family_responses_are_exact_round_trips() {
10817        let responses = vec![
10818            HarnessOperatorResponseV1::HostDirectoriesBrowsed(HarnessHostDirectoryListingV1 {
10819                directory: Some(host_path(r"C:\fixtures")),
10820                parent: Some(host_path(r"C:\")),
10821                entries: vec![HarnessHostDirectoryEntryV1 {
10822                    path: host_path(r"C:\fixtures\workspace"),
10823                    display_name: "workspace".to_owned(),
10824                    is_link: false,
10825                }],
10826                next_after: Some(host_path(r"C:\fixtures\workspace")),
10827                incomplete: true,
10828            }),
10829            HarnessOperatorResponseV1::WorkspaceRegistered(sample_workspace_snapshot("workspace-b")),
10830            HarnessOperatorResponseV1::StandaloneWorkspaceCreated(
10831                sample_workspace_snapshot("workspace-c"),
10832            ),
10833            HarnessOperatorResponseV1::WorkspaceUnregistered {
10834                workspace_id: "workspace-b".to_owned(),
10835            },
10836            HarnessOperatorResponseV1::WorktreeCreated {
10837                worktree: sample_git_worktree_snapshot(),
10838                workspace: sample_workspace_snapshot("worktree-workspace"),
10839            },
10840            HarnessOperatorResponseV1::WorktreeRemoved {
10841                target_root: host_path(r"C:\fixtures\worktree"),
10842                workspace_id: Some("worktree-workspace".to_owned()),
10843            },
10844            HarnessOperatorResponseV1::ContextPackExported(HarnessResolvedContextPackReceiptV1 {
10845                id: HarnessSelectorV1::new("context-a").unwrap(),
10846                digest: "sha256:".to_owned() + &"a".repeat(64),
10847                lineage: HarnessContextPackLineageV1 {
10848                    source_node_id: HarnessSelectorV1::new("node-a").unwrap(),
10849                    source_workspace_id: HarnessSelectorV1::new("workspace-a").unwrap(),
10850                    source_instance_id: 41,
10851                    source_generation: 3,
10852                    source_provider: HarnessSelectorV1::new("codex").unwrap(),
10853                },
10854                source_message_count: 4,
10855                retained_message_count: 4,
10856                byte_len: 128,
10857                truncated: false,
10858            }),
10859            HarnessOperatorResponseV1::ContextPackForgotten { context_id: "context-a".to_owned() },
10860        ];
10861        for response in responses {
10862            response.validate().expect("valid resource-mutation family response");
10863            let encoded = serde_json::to_string(&response).unwrap();
10864            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10865            assert_eq!(decoded, response);
10866        }
10867    }
10868
10869    #[test]
10870    fn resource_mutation_family_requests_reject_malformed_fields() {
10871        let bad_node_id = HarnessOperatorRequestV1::BrowseHostDirectories {
10872            node_id: String::new(),
10873            directory: None,
10874            after: None,
10875        };
10876        assert!(matches!(
10877            bad_node_id.validate(),
10878            Err(HarnessOperatorApiError::InvalidHostDirectoryBrowseRequest),
10879        ));
10880
10881        assert!(matches!(
10882            HarnessHostPathV1::new(""),
10883            Err(HarnessOperatorApiError::InvalidHostPath),
10884        ));
10885        assert!(matches!(
10886            HarnessHostPathV1::new("x".repeat(HARNESS_HOST_PATH_MAX_BYTES + 1)),
10887            Err(HarnessOperatorApiError::InvalidHostPath),
10888        ));
10889        HarnessHostPathV1::new("x".repeat(HARNESS_HOST_PATH_MAX_BYTES)).unwrap();
10890
10891        let bad_workspace_id = HarnessOperatorRequestV1::RegisterWorkspace {
10892            node_id: "node-a".to_owned(),
10893            workspace_id: String::new(),
10894            root: host_path(r"C:\fixtures\workspace-b"),
10895        };
10896        assert!(matches!(
10897            bad_workspace_id.validate(),
10898            Err(HarnessOperatorApiError::InvalidResourceMutationRequest),
10899        ));
10900
10901        let empty_branch = HarnessOperatorRequestV1::CreateWorktree {
10902            node_id: "node-a".to_owned(),
10903            source_workspace_id: "workspace-a".to_owned(),
10904            workspace_id: "worktree-workspace".to_owned(),
10905            target_root: host_path(r"C:\fixtures\worktree"),
10906            branch: String::new(),
10907            base: None,
10908        };
10909        assert!(matches!(
10910            empty_branch.validate(),
10911            Err(HarnessOperatorApiError::InvalidResourceMutationRequest),
10912        ));
10913
10914        let control_char_base = HarnessOperatorRequestV1::CreateWorktree {
10915            node_id: "node-a".to_owned(),
10916            source_workspace_id: "workspace-a".to_owned(),
10917            workspace_id: "worktree-workspace".to_owned(),
10918            target_root: host_path(r"C:\fixtures\worktree"),
10919            branch: "feature/resource-mutation".to_owned(),
10920            base: Some("main\u{0007}".to_owned()),
10921        };
10922        assert!(matches!(
10923            control_char_base.validate(),
10924            Err(HarnessOperatorApiError::InvalidResourceMutationRequest),
10925        ));
10926
10927        let mut malformed_session = session_address(41, 3);
10928        malformed_session.instance_id = 0;
10929        assert!(
10930            HarnessOperatorRequestV1::ExportContextPack { session: malformed_session }
10931                .validate()
10932                .is_err()
10933        );
10934
10935        let bad_context_id = HarnessOperatorRequestV1::ForgetContextPack {
10936            node_id: "node-a".to_owned(),
10937            context_id: HarnessSelectorV1::new("context-a").unwrap(),
10938        };
10939        bad_context_id.validate().unwrap();
10940        let empty_forget_node_id = HarnessOperatorRequestV1::ForgetContextPack {
10941            node_id: String::new(),
10942            context_id: HarnessSelectorV1::new("context-a").unwrap(),
10943        };
10944        assert!(matches!(
10945            empty_forget_node_id.validate(),
10946            Err(HarnessOperatorApiError::InvalidResourceMutationRequest),
10947        ));
10948    }
10949
10950    #[test]
10951    fn resource_mutation_family_responses_reject_malformed_fields() {
10952        assert!(
10953            HarnessOperatorResponseV1::WorkspaceUnregistered { workspace_id: String::new() }
10954                .validate()
10955                .is_err()
10956        );
10957
10958        let mut malformed_workspace = sample_workspace_snapshot("workspace-b");
10959        malformed_workspace.workspace_id = String::new();
10960        assert!(
10961            HarnessOperatorResponseV1::WorkspaceRegistered(malformed_workspace)
10962                .validate()
10963                .is_err()
10964        );
10965
10966        let mut malformed_worktree = sample_git_worktree_snapshot();
10967        malformed_worktree.head = String::new();
10968        assert!(
10969            HarnessOperatorResponseV1::WorktreeCreated {
10970                worktree: malformed_worktree,
10971                workspace: sample_workspace_snapshot("worktree-workspace"),
10972            }.validate().is_err()
10973        );
10974
10975        assert!(
10976            HarnessOperatorResponseV1::WorktreeRemoved {
10977                target_root: host_path(r"C:\fixtures\worktree"),
10978                workspace_id: Some(String::new()),
10979            }.validate().is_err()
10980        );
10981
10982        assert!(
10983            HarnessOperatorResponseV1::ContextPackForgotten { context_id: String::new() }
10984                .validate()
10985                .is_err()
10986        );
10987    }
10988
10989    fn sample_redacted_task(id_byte: char) -> RedactedTaskV1 {
10990        RedactedTaskV1 {
10991            task_id: HarnessTaskId::new(format!("htask_{}", id_byte.to_string().repeat(24)))
10992                .unwrap(),
10993            revision: HarnessRevision::new(1).unwrap(),
10994            title: "Task title".to_owned(),
10995            body: String::new(),
10996            creator: TaskCreatorCategoryV1::User,
10997            parent_task_id: None,
10998            dependency_ids: Vec::new(),
10999            state: HarnessTaskStateV1::Backlog,
11000            run_ids: Vec::new(),
11001            references_redacted: false,
11002            result_refs: Vec::new(),
11003            artifact_refs: Vec::new(),
11004            created_at_unix_ms: 10,
11005            updated_at_unix_ms: 10,
11006        }
11007    }
11008
11009    fn sample_redacted_run(id_byte: char) -> RedactedRunV1 {
11010        RedactedRunV1 {
11011            run_id: HarnessRunId::new(format!("hrun_{}", id_byte.to_string().repeat(24)))
11012                .unwrap(),
11013            revision: HarnessRevision::new(1).unwrap(),
11014            parent_run_id: None,
11015            task_id: None,
11016            operation_id: None,
11017            intent: RedactedRunIntentV1 {
11018                mode: HarnessExecutionModeV1::Pty,
11019                worktree: RedactedWorktreeIntentV1::Existing,
11020                has_delivery_bundle: false,
11021                has_continuation: false,
11022            },
11023            lifecycle: HarnessRunLifecycleV1::Requested,
11024            binding: RedactedBindingStateV1::None,
11025            result_disposition: None,
11026            failure_category: None,
11027            context_pack: None,
11028            git_facts: None,
11029            delivery_receipt: None,
11030            references_redacted: false,
11031            created_at_unix_ms: 10,
11032            updated_at_unix_ms: 10,
11033        }
11034    }
11035
11036    fn simple_node_inventory(node_id: &str) -> HarnessRuntimeNodeInventoryV1 {
11037        HarnessRuntimeNodeInventoryV1 {
11038            node_id: node_id.to_owned(),
11039            incarnation_id: "07".repeat(16),
11040            observed_at_unix_ms: 1_000,
11041            event_sequence: 1,
11042            inventory: HarnessRuntimeInventoryV1 {
11043                enabled_providers: Vec::new(),
11044                workspaces: BTreeMap::new(),
11045                workspace_count: 0,
11046                workspaces_truncated: false,
11047                session_count: 0,
11048                sessions_truncated: false,
11049                managed_sessions: Vec::new(),
11050                managed_session_count: 0,
11051                managed_sessions_truncated: false,
11052                retired_count: 0,
11053                launch_inventory: None,
11054            },
11055        }
11056    }
11057
11058    #[test]
11059    fn operator_subscribe_events_is_exact_round_trip() {
11060        let request = HarnessOperatorRequestV1::SubscribeEvents {};
11061        request.validate().expect("valid subscribe request");
11062        let encoded = serde_json::to_string(&request).unwrap();
11063        let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
11064        assert_eq!(decoded, request);
11065
11066        let credential = HarnessOperatorCredential::parse(format!(
11067            "g4aho_{}",
11068            "a".repeat(64),
11069        )).unwrap();
11070        HarnessOperatorEnvelopeV1 {
11071            build_stamp: BUILD_STAMP.to_string(),
11072            credential,
11073            request,
11074        }.validate().unwrap();
11075    }
11076
11077    #[test]
11078    fn operator_subscribe_terminal_is_exact_round_trip() {
11079        let request = HarnessOperatorRequestV1::SubscribeTerminal {
11080            sessions: vec![session_address(1, 1), session_address(2, 1)],
11081        };
11082        request.validate().expect("valid subscribe-terminal request");
11083        let encoded = serde_json::to_string(&request).unwrap();
11084        let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
11085        assert_eq!(decoded, request);
11086
11087        let credential = HarnessOperatorCredential::parse(format!(
11088            "g4aho_{}",
11089            "a".repeat(64),
11090        )).unwrap();
11091        HarnessOperatorEnvelopeV1 {
11092            build_stamp: BUILD_STAMP.to_string(),
11093            credential,
11094            request,
11095        }.validate().unwrap();
11096    }
11097
11098    /// The single accepted build stamp: a foreign stamp is rejected, and the
11099    /// error names both the expected and the received stamp -- the
11100    /// diagnostic a silent binary-skew incident previously had neither of.
11101    #[test]
11102    fn envelope_with_a_foreign_build_stamp_is_rejected_naming_both_values() {
11103        let credential = HarnessOperatorCredential::parse(format!(
11104            "g4aho_{}",
11105            "a".repeat(64),
11106        )).unwrap();
11107        let foreign_stamp = "f".repeat(40);
11108        let envelope = HarnessOperatorEnvelopeV1 {
11109            build_stamp: foreign_stamp.clone(),
11110            credential,
11111            request: HarnessOperatorRequestV1::SubscribeEvents {},
11112        };
11113        let error = envelope
11114            .validate()
11115            .expect_err("a foreign build stamp must be rejected");
11116        assert!(matches!(
11117            &error,
11118            HarnessOperatorApiError::BuildStampMismatch { expected, received }
11119                if expected == BUILD_STAMP && received == &foreign_stamp,
11120        ));
11121        let message = error.to_string();
11122        assert!(message.contains(BUILD_STAMP));
11123        assert!(message.contains(&foreign_stamp));
11124    }
11125
11126    #[test]
11127    fn subscribe_terminal_rejects_empty_oversized_and_duplicate_session_lists() {
11128        assert!(matches!(
11129            HarnessOperatorRequestV1::SubscribeTerminal { sessions: Vec::new() }.validate(),
11130            Err(HarnessOperatorApiError::InvalidTerminalPage),
11131        ));
11132
11133        let oversized = (0..=HARNESS_TERMINAL_SUBSCRIPTION_SESSIONS_MAX as u64)
11134            .map(|instance_id| session_address(instance_id + 1, 1))
11135            .collect::<Vec<_>>();
11136        assert!(matches!(
11137            HarnessOperatorRequestV1::SubscribeTerminal { sessions: oversized }.validate(),
11138            Err(HarnessOperatorApiError::InvalidTerminalPage),
11139        ));
11140
11141        let duplicated = vec![session_address(1, 1), session_address(1, 1)];
11142        assert!(matches!(
11143            HarnessOperatorRequestV1::SubscribeTerminal { sessions: duplicated }.validate(),
11144            Err(HarnessOperatorApiError::InvalidTerminalPage),
11145        ));
11146
11147        let mut malformed = session_address(1, 1);
11148        malformed.node_id = String::new();
11149        assert!(HarnessOperatorRequestV1::SubscribeTerminal { sessions: vec![malformed] }
11150            .validate()
11151            .is_err());
11152    }
11153
11154    #[test]
11155    fn operator_event_variants_are_exact_round_trips() {
11156        let task = sample_redacted_task('a');
11157        let run = sample_redacted_run('b');
11158        let node = simple_node_inventory("node-a");
11159        let events = vec![
11160            HarnessOperatorEventV1::SnapshotBaseline {
11161                sequence: 0,
11162                tasks: vec![task.clone()],
11163                runs: vec![run.clone()],
11164                nodes: vec![node.clone()],
11165            },
11166            HarnessOperatorEventV1::TaskChanged { sequence: 1, task: task.clone() },
11167            HarnessOperatorEventV1::RunChanged { sequence: 2, run: run.clone() },
11168            HarnessOperatorEventV1::RuntimeInventoryChanged { sequence: 3, node: node.clone() },
11169            HarnessOperatorEventV1::RuntimeInventoryRemoved {
11170                sequence: 4,
11171                node_id: "node-a".to_owned(),
11172            },
11173            HarnessOperatorEventV1::Lagged { sequence: 5 },
11174        ];
11175        for event in events {
11176            event.validate().expect("valid operator event");
11177            let encoded = serde_json::to_string(&event).unwrap();
11178            let decoded: HarnessOperatorEventV1 = serde_json::from_str(&encoded).unwrap();
11179            assert_eq!(decoded, event);
11180        }
11181    }
11182
11183    #[test]
11184    fn operator_terminal_event_variants_are_exact_round_trips() {
11185        let events = vec![
11186            HarnessOperatorTerminalEventV1::TerminalFrame {
11187                sequence: 0,
11188                session: session_address(1, 1),
11189                frame: sample_terminal_frame(1),
11190                coalesced_since_last: 0,
11191            },
11192            HarnessOperatorTerminalEventV1::TerminalFrame {
11193                sequence: 1,
11194                session: session_address(1, 1),
11195                frame: sample_terminal_frame(2),
11196                coalesced_since_last: 3,
11197            },
11198            HarnessOperatorTerminalEventV1::Ping { sequence: 2 },
11199        ];
11200        for event in events {
11201            event.validate().expect("valid operator terminal event");
11202            let encoded = serde_json::to_string(&event).unwrap();
11203            let decoded: HarnessOperatorTerminalEventV1 = serde_json::from_str(&encoded).unwrap();
11204            assert_eq!(decoded, event);
11205        }
11206    }
11207
11208    #[test]
11209    fn operator_terminal_event_validate_rejects_a_malformed_frame_or_session() {
11210        let mut bad_session = session_address(1, 1);
11211        bad_session.node_id = String::new();
11212        let bad_session_event = HarnessOperatorTerminalEventV1::TerminalFrame {
11213            sequence: 0,
11214            session: bad_session,
11215            frame: sample_terminal_frame(1),
11216            coalesced_since_last: 0,
11217        };
11218        assert!(matches!(
11219            bad_session_event.validate(),
11220            Err(HarnessOperatorApiError::InvalidTerminalPage),
11221        ));
11222
11223        let mut zero_size_frame = sample_terminal_frame(1);
11224        zero_size_frame.size.rows = 0;
11225        let bad_frame_event = HarnessOperatorTerminalEventV1::TerminalFrame {
11226            sequence: 0,
11227            session: session_address(1, 1),
11228            frame: zero_size_frame,
11229            coalesced_since_last: 0,
11230        };
11231        assert!(matches!(
11232            bad_frame_event.validate(),
11233            Err(HarnessOperatorApiError::InvalidTerminalPage),
11234        ));
11235    }
11236
11237    #[test]
11238    fn operator_event_validate_rejects_malformed_frames() {
11239        let task_a = sample_redacted_task('a');
11240        let task_b = sample_redacted_task('b');
11241        let out_of_order = HarnessOperatorEventV1::SnapshotBaseline {
11242            sequence: 0,
11243            tasks: vec![task_b, task_a],
11244            runs: Vec::new(),
11245            nodes: Vec::new(),
11246        };
11247        assert!(out_of_order.validate().is_err());
11248
11249        let bad_node_id = HarnessOperatorEventV1::RuntimeInventoryRemoved {
11250            sequence: 0,
11251            node_id: String::new(),
11252        };
11253        assert!(matches!(
11254            bad_node_id.validate(),
11255            Err(HarnessOperatorApiError::InvalidRuntimeInventory),
11256        ));
11257
11258        let mut malformed_task = sample_redacted_task('c');
11259        malformed_task.title = "\0".to_owned();
11260        let bad_task = HarnessOperatorEventV1::TaskChanged { sequence: 0, task: malformed_task };
11261        assert!(bad_task.validate().is_err());
11262    }
11263
11264    /// `HarnessOperatorHostErrorV1::Unsupported` (added alongside
11265    /// `hatchery-harness-light`, see its own doc comment): a purely
11266    /// additive unit variant, so this only needs to prove it round-trips --
11267    /// on its own, and wrapped in the `HarnessOperatorReplyV1::Error` shape
11268    /// every operator host error actually rides on the wire.
11269    #[test]
11270    fn operator_host_error_unsupported_round_trips() {
11271        let error = HarnessOperatorHostErrorV1::Unsupported;
11272        let encoded = serde_json::to_string(&error).unwrap();
11273        assert_eq!(encoded, "\"unsupported\"");
11274        assert_eq!(serde_json::from_str::<HarnessOperatorHostErrorV1>(&encoded).unwrap(), error);
11275
11276        let reply = HarnessOperatorReplyV1::Error { error };
11277        assert!(reply.validate().is_ok());
11278        let encoded_reply = serde_json::to_vec(&reply).unwrap();
11279        let decoded_reply: HarnessOperatorReplyV1 = serde_json::from_slice(&encoded_reply).unwrap();
11280        assert_eq!(decoded_reply, reply);
11281    }
11282
11283    #[test]
11284    fn operator_acp_control_verbs_are_exact_round_trips() {
11285        let session = session_address(7, 2);
11286        let requests = vec![
11287            HarnessOperatorRequestV1::ResolveInteraction {
11288                session: session.clone(),
11289                correlation_id: "a".repeat(HARNESS_OBSERVATION_LABEL_MAX_BYTES),
11290                response: HarnessProviderInteractionResponseV1::ApproveOnce,
11291            },
11292            HarnessOperatorRequestV1::ResolveInteraction {
11293                session: session.clone(),
11294                correlation_id: "correlation-1".to_owned(),
11295                response: HarnessProviderInteractionResponseV1::Deny,
11296            },
11297            HarnessOperatorRequestV1::ResolveInteraction {
11298                session: session.clone(),
11299                correlation_id: "correlation-2".to_owned(),
11300                response: HarnessProviderInteractionResponseV1::Answer {
11301                    text: "yes, proceed".to_owned(),
11302                },
11303            },
11304            HarnessOperatorRequestV1::SetSessionMode {
11305                session: session.clone(),
11306                mode_id: "plan".to_owned(),
11307            },
11308            HarnessOperatorRequestV1::SetSessionConfigOption {
11309                session: session.clone(),
11310                option_id: "reasoning-effort".to_owned(),
11311                value_json: "\"high\"".to_owned(),
11312            },
11313            HarnessOperatorRequestV1::SetSessionModel {
11314                session: session.clone(),
11315                model_id: "grok-4".to_owned(),
11316            },
11317        ];
11318        let credential = HarnessOperatorCredential::parse(format!(
11319            "g4aho_{}",
11320            "a".repeat(64),
11321        )).unwrap();
11322        for request in requests {
11323            request.validate().expect("valid ACP control verb request");
11324            let encoded = serde_json::to_string(&request).unwrap();
11325            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
11326            assert_eq!(decoded, request);
11327            HarnessOperatorEnvelopeV1 {
11328                build_stamp: BUILD_STAMP.to_string(),
11329                credential: credential.clone(),
11330                request,
11331            }.validate().unwrap();
11332        }
11333
11334        let responses = vec![
11335            HarnessOperatorResponseV1::InteractionResolved,
11336            HarnessOperatorResponseV1::SessionModeSet,
11337            HarnessOperatorResponseV1::SessionConfigOptionSet,
11338            HarnessOperatorResponseV1::SessionModelSet,
11339        ];
11340        for response in responses {
11341            response.validate().expect("valid ACP control verb response");
11342            let encoded = serde_json::to_string(&response).unwrap();
11343            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
11344            assert_eq!(decoded, response);
11345        }
11346    }
11347
11348    #[test]
11349    fn acp_control_requests_reject_malformed_fields() {
11350        let session = session_address(7, 2);
11351
11352        let empty_correlation = HarnessOperatorRequestV1::ResolveInteraction {
11353            session: session.clone(),
11354            correlation_id: String::new(),
11355            response: HarnessProviderInteractionResponseV1::ApproveOnce,
11356        };
11357        assert!(matches!(
11358            empty_correlation.validate(),
11359            Err(HarnessOperatorApiError::InvalidSessionControl),
11360        ));
11361
11362        let oversized_correlation = HarnessOperatorRequestV1::ResolveInteraction {
11363            session: session.clone(),
11364            correlation_id: "a".repeat(HARNESS_OBSERVATION_LABEL_MAX_BYTES + 1),
11365            response: HarnessProviderInteractionResponseV1::Deny,
11366        };
11367        assert!(matches!(
11368            oversized_correlation.validate(),
11369            Err(HarnessOperatorApiError::InvalidSessionControl),
11370        ));
11371
11372        let oversized_answer = HarnessOperatorRequestV1::ResolveInteraction {
11373            session: session.clone(),
11374            correlation_id: "correlation-1".to_owned(),
11375            response: HarnessProviderInteractionResponseV1::Answer {
11376                text: "x".repeat(HARNESS_ACP_INTERACTION_RESPONSE_MAX_BYTES + 1),
11377            },
11378        };
11379        assert!(matches!(
11380            oversized_answer.validate(),
11381            Err(HarnessOperatorApiError::InvalidSessionControl),
11382        ));
11383
11384        let empty_mode_id = HarnessOperatorRequestV1::SetSessionMode {
11385            session: session.clone(),
11386            mode_id: String::new(),
11387        };
11388        assert!(matches!(
11389            empty_mode_id.validate(),
11390            Err(HarnessOperatorApiError::InvalidSessionControl),
11391        ));
11392
11393        let control_char_option_id = HarnessOperatorRequestV1::SetSessionConfigOption {
11394            session: session.clone(),
11395            option_id: "bad\u{0007}id".to_owned(),
11396            value_json: "true".to_owned(),
11397        };
11398        assert!(matches!(
11399            control_char_option_id.validate(),
11400            Err(HarnessOperatorApiError::InvalidSessionControl),
11401        ));
11402
11403        let empty_value_json = HarnessOperatorRequestV1::SetSessionConfigOption {
11404            session: session.clone(),
11405            option_id: "reasoning-effort".to_owned(),
11406            value_json: String::new(),
11407        };
11408        assert!(matches!(
11409            empty_value_json.validate(),
11410            Err(HarnessOperatorApiError::InvalidSessionControl),
11411        ));
11412
11413        let oversized_model_id = HarnessOperatorRequestV1::SetSessionModel {
11414            session: session.clone(),
11415            model_id: "x".repeat(HARNESS_AGENT_STREAM_ID_MAX_BYTES + 1),
11416        };
11417        assert!(matches!(
11418            oversized_model_id.validate(),
11419            Err(HarnessOperatorApiError::InvalidSessionControl),
11420        ));
11421
11422        let mut malformed_session = session.clone();
11423        malformed_session.instance_id = 0;
11424        let bad_session = HarnessOperatorRequestV1::SetSessionModel {
11425            session: malformed_session,
11426            model_id: "grok-4".to_owned(),
11427        };
11428        assert!(bad_session.validate().is_err());
11429    }
11430
11431    #[test]
11432    fn operator_subscribe_agent_stream_is_exact_round_trip() {
11433        let request = HarnessOperatorRequestV1::SubscribeAgentStream {
11434            sessions: vec![session_address(1, 1), session_address(2, 1)],
11435        };
11436        request.validate().expect("valid subscribe-agent-stream request");
11437        let encoded = serde_json::to_string(&request).unwrap();
11438        let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
11439        assert_eq!(decoded, request);
11440
11441        let credential = HarnessOperatorCredential::parse(format!(
11442            "g4aho_{}",
11443            "a".repeat(64),
11444        )).unwrap();
11445        HarnessOperatorEnvelopeV1 {
11446            build_stamp: BUILD_STAMP.to_string(),
11447            credential,
11448            request,
11449        }.validate().unwrap();
11450    }
11451
11452    #[test]
11453    fn subscribe_agent_stream_rejects_empty_oversized_and_duplicate_session_lists() {
11454        assert!(matches!(
11455            HarnessOperatorRequestV1::SubscribeAgentStream { sessions: Vec::new() }.validate(),
11456            Err(HarnessOperatorApiError::InvalidAgentStream),
11457        ));
11458
11459        let oversized = (0..=HARNESS_AGENT_STREAM_SUBSCRIPTION_SESSIONS_MAX as u64)
11460            .map(|instance_id| session_address(instance_id + 1, 1))
11461            .collect::<Vec<_>>();
11462        assert!(matches!(
11463            HarnessOperatorRequestV1::SubscribeAgentStream { sessions: oversized }.validate(),
11464            Err(HarnessOperatorApiError::InvalidAgentStream),
11465        ));
11466
11467        let duplicated = vec![session_address(1, 1), session_address(1, 1)];
11468        assert!(matches!(
11469            HarnessOperatorRequestV1::SubscribeAgentStream { sessions: duplicated }.validate(),
11470            Err(HarnessOperatorApiError::InvalidAgentStream),
11471        ));
11472
11473        let mut malformed = session_address(1, 1);
11474        malformed.node_id = String::new();
11475        assert!(HarnessOperatorRequestV1::SubscribeAgentStream { sessions: vec![malformed] }
11476            .validate()
11477            .is_err());
11478    }
11479
11480    #[test]
11481    fn operator_agent_event_variants_are_exact_round_trips() {
11482        let session = session_address(1, 1);
11483        let chunks = vec![
11484            HarnessAgentStreamChunkKindV1::Text { text: "hello".to_owned(), is_delta: false },
11485            HarnessAgentStreamChunkKindV1::Thinking { text: "considering options".to_owned() },
11486            HarnessAgentStreamChunkKindV1::InteractionPrompt {
11487                correlation_id: "correlation-1".to_owned(),
11488                interaction_kind: HarnessProviderInteractionKindV1::Approval,
11489                tool_name: "execute".to_owned(),
11490                title: Some("Run command?".to_owned()),
11491                prompt: "rm -rf /tmp/scratch".to_owned(),
11492                options: vec![HarnessAgentStreamInteractionOptionV1 {
11493                    option_id: "allow".to_owned(),
11494                    name: "Allow".to_owned(),
11495                    kind: "allow_once".to_owned(),
11496                }],
11497            },
11498            HarnessAgentStreamChunkKindV1::ModeCatalog {
11499                current: Some("plan".to_owned()),
11500                available: vec![HarnessAgentStreamNamedIdV1 {
11501                    id: "plan".to_owned(),
11502                    name: "Plan".to_owned(),
11503                    description: None,
11504                }],
11505            },
11506            HarnessAgentStreamChunkKindV1::ConfigOptions {
11507                options: vec![HarnessProviderConfigOptionV1 {
11508                    id: "reasoning-effort".to_owned(),
11509                    name: "Reasoning effort".to_owned(),
11510                    description: Some("How hard to think".to_owned()),
11511                    category: Some("model".to_owned()),
11512                    kind: HarnessProviderConfigOptionKindV1::Select,
11513                    value_json: "\"high\"".to_owned(),
11514                    choices: vec![HarnessProviderConfigChoiceV1 {
11515                        value_json: "\"high\"".to_owned(),
11516                        label: Some("High".to_owned()),
11517                    }],
11518                }],
11519            },
11520            HarnessAgentStreamChunkKindV1::ModelCatalog {
11521                current: Some("grok-4".to_owned()),
11522                available: vec![HarnessAgentStreamNamedIdV1 {
11523                    id: "grok-4".to_owned(),
11524                    name: "Grok 4".to_owned(),
11525                    description: None,
11526                }],
11527            },
11528        ];
11529
11530        let mut events: Vec<HarnessOperatorAgentEventV1> = chunks
11531            .into_iter()
11532            .enumerate()
11533            .map(|(index, kind)| HarnessOperatorAgentEventV1::AgentChunk {
11534                sequence: index as u64,
11535                session: session.clone(),
11536                chunk: HarnessAgentStreamChunkV1 { source_sequence: index as u64, kind },
11537                published_at_ms: 1_000 + index as u64,
11538            })
11539            .collect();
11540        events.push(HarnessOperatorAgentEventV1::Lagged {
11541            sequence: 100,
11542            session: session.clone(),
11543            dropped: 3,
11544        });
11545        events.push(HarnessOperatorAgentEventV1::ReplayBoundary {
11546            session: session.clone(),
11547            replayed: 6,
11548            dropped_before_replay: 44,
11549        });
11550        events.push(HarnessOperatorAgentEventV1::Ping { sequence: 101 });
11551
11552        for event in events {
11553            event.validate().expect("valid operator agent event");
11554            let encoded = serde_json::to_string(&event).unwrap();
11555            let decoded: HarnessOperatorAgentEventV1 = serde_json::from_str(&encoded).unwrap();
11556            assert_eq!(decoded, event);
11557        }
11558    }
11559
11560    /// `TasksList`/`RunsList` gain a parent filter ("what did this task/run
11561    /// spawn") additive over the pre-existing shape via `#[serde(default)]`:
11562    /// an old-shape request with no `parent_task_id`/`parent_run_id` key
11563    /// still decodes (defaulting to `None`), and a set value round-trips.
11564    /// A malformed id can never reach `parent_task_id`/
11565    /// `parent_run_id` in the first place -- `HarnessTaskId::new`/
11566    /// `HarnessRunId::new` are the validation, refusing a malformed string
11567    /// before a request carrying one could ever be built (the CLI's own
11568    /// rejection of a malformed `--parent`/`--parent-run` flag, tested in
11569    /// `hatchery-harnessctl`, goes through this exact constructor).
11570    #[test]
11571    fn parent_filters_default_on_old_shape_and_round_trip() {
11572        let task_id = HarnessTaskId::new(format!("htask_{}", "a".repeat(24))).unwrap();
11573        let tasks_request = HarnessReadRequestV1::TasksList {
11574            after_task_id: None,
11575            state: None,
11576            parent_task_id: Some(task_id.clone()),
11577            limit: 10,
11578        };
11579        tasks_request.validate().unwrap();
11580        let encoded_tasks = serde_json::to_string(&tasks_request).unwrap();
11581        assert_eq!(
11582            serde_json::from_str::<HarnessReadRequestV1>(&encoded_tasks).unwrap(),
11583            tasks_request,
11584        );
11585
11586        let old_shape_tasks = serde_json::json!({
11587            "kind": "tasks-list",
11588            "after_task_id": null,
11589            "state": null,
11590            "limit": 10,
11591        });
11592        let decoded_tasks: HarnessReadRequestV1 = serde_json::from_value(old_shape_tasks).unwrap();
11593        match &decoded_tasks {
11594            HarnessReadRequestV1::TasksList { parent_task_id, .. } => assert!(parent_task_id.is_none()),
11595            other => panic!("expected TasksList, got {other:?}"),
11596        }
11597        decoded_tasks.validate().unwrap();
11598        assert!(HarnessTaskId::new("not-a-task-id").is_err());
11599
11600        let run_id = HarnessRunId::new(format!("hrun_{}", "b".repeat(24))).unwrap();
11601        let runs_request = HarnessReadRequestV1::RunsList {
11602            task_id: None,
11603            after_run_id: None,
11604            lifecycle: None,
11605            parent_run_id: Some(run_id.clone()),
11606            limit: 10,
11607        };
11608        runs_request.validate().unwrap();
11609        let encoded_runs = serde_json::to_string(&runs_request).unwrap();
11610        assert_eq!(
11611            serde_json::from_str::<HarnessReadRequestV1>(&encoded_runs).unwrap(),
11612            runs_request,
11613        );
11614
11615        let old_shape_runs = serde_json::json!({
11616            "kind": "runs-list",
11617            "task_id": null,
11618            "after_run_id": null,
11619            "lifecycle": null,
11620            "limit": 10,
11621        });
11622        let decoded_runs: HarnessReadRequestV1 = serde_json::from_value(old_shape_runs).unwrap();
11623        match &decoded_runs {
11624            HarnessReadRequestV1::RunsList { parent_run_id, .. } => assert!(parent_run_id.is_none()),
11625            other => panic!("expected RunsList, got {other:?}"),
11626        }
11627        decoded_runs.validate().unwrap();
11628        assert!(HarnessRunId::new("not-a-run-id").is_err());
11629    }
11630
11631    #[test]
11632    fn operator_agent_event_validate_rejects_malformed_chunks_or_sessions() {
11633        let session = session_address(1, 1);
11634
11635        let mut bad_session = session.clone();
11636        bad_session.node_id = String::new();
11637        let bad_session_event = HarnessOperatorAgentEventV1::AgentChunk {
11638            sequence: 0,
11639            session: bad_session,
11640            chunk: sample_agent_stream_chunk(0),
11641            published_at_ms: 1_000,
11642        };
11643        assert!(bad_session_event.validate().is_err());
11644
11645        let oversized_text_chunk = HarnessAgentStreamChunkV1 {
11646            source_sequence: 0,
11647            kind: HarnessAgentStreamChunkKindV1::Text {
11648                text: "x".repeat(HARNESS_AGENT_STREAM_TEXT_MAX_BYTES + 1),
11649                is_delta: false,
11650            },
11651        };
11652        let bad_chunk_event = HarnessOperatorAgentEventV1::AgentChunk {
11653            sequence: 0,
11654            session: session.clone(),
11655            chunk: oversized_text_chunk,
11656            published_at_ms: 1_000,
11657        };
11658        assert!(matches!(
11659            bad_chunk_event.validate(),
11660            Err(HarnessOperatorApiError::InvalidAgentStream),
11661        ));
11662
11663        let empty_correlation_prompt = HarnessAgentStreamChunkV1 {
11664            source_sequence: 0,
11665            kind: HarnessAgentStreamChunkKindV1::InteractionPrompt {
11666                correlation_id: String::new(),
11667                interaction_kind: HarnessProviderInteractionKindV1::Question,
11668                tool_name: "ask".to_owned(),
11669                title: None,
11670                prompt: "What next?".to_owned(),
11671                options: Vec::new(),
11672            },
11673        };
11674        let bad_prompt_event = HarnessOperatorAgentEventV1::AgentChunk {
11675            sequence: 0,
11676            session: session.clone(),
11677            chunk: empty_correlation_prompt,
11678            published_at_ms: 1_000,
11679        };
11680        assert!(matches!(
11681            bad_prompt_event.validate(),
11682            Err(HarnessOperatorApiError::InvalidAgentStream),
11683        ));
11684    }
11685
11686    /// D5, Slice D: `g4a_task_create`'s wire request round-trips and bounds
11687    /// title/body at this coarse wire-level ceiling -- the authoritative
11688    /// rule (trim/control-character checks) is the engine's own
11689    /// `HarnessTaskV1::validate`, reached at apply time, not this validate().
11690    #[test]
11691    fn task_create_request_round_trips_and_bounds_title_and_body() {
11692        let request = HarnessReadRequestV1::TaskCreate {
11693            title: "Investigate the flaky retry".to_owned(),
11694            body: "See the timeline for the failing run.".to_owned(),
11695            parent_task_id: Some(HarnessTaskId::new(format!("htask_{}", "a".repeat(24))).unwrap()),
11696        };
11697        request.validate().unwrap();
11698        let encoded = serde_json::to_string(&request).unwrap();
11699        assert_eq!(serde_json::from_str::<HarnessReadRequestV1>(&encoded).unwrap(), request);
11700
11701        let no_parent = HarnessReadRequestV1::TaskCreate {
11702            title: "Child under my own task".to_owned(),
11703            body: String::new(),
11704            parent_task_id: None,
11705        };
11706        no_parent.validate().unwrap();
11707
11708        let empty_title = HarnessReadRequestV1::TaskCreate {
11709            title: String::new(),
11710            body: String::new(),
11711            parent_task_id: None,
11712        };
11713        assert!(matches!(empty_title.validate(), Err(HarnessReadApiError::InvalidTaskCreate)));
11714
11715        let oversized_title = HarnessReadRequestV1::TaskCreate {
11716            title: "x".repeat(HARNESS_TITLE_MAX_BYTES + 1),
11717            body: String::new(),
11718            parent_task_id: None,
11719        };
11720        assert!(matches!(oversized_title.validate(), Err(HarnessReadApiError::InvalidTaskCreate)));
11721
11722        let oversized_body = HarnessReadRequestV1::TaskCreate {
11723            title: "ok".to_owned(),
11724            body: "x".repeat(HARNESS_BODY_MAX_BYTES + 1),
11725            parent_task_id: None,
11726        };
11727        assert!(matches!(oversized_body.validate(), Err(HarnessReadApiError::InvalidTaskCreate)));
11728    }
11729
11730    /// D5, Slice D: `g4a_task_move`'s wire request round-trips.
11731    #[test]
11732    fn task_move_request_round_trips() {
11733        let request = HarnessReadRequestV1::TaskMove {
11734            task_id: HarnessTaskId::new(format!("htask_{}", "b".repeat(24))).unwrap(),
11735            expected_revision: HarnessRevision::new(3).unwrap(),
11736            to: HarnessTaskStateV1::Ready,
11737        };
11738        request.validate().unwrap();
11739        let encoded = serde_json::to_string(&request).unwrap();
11740        assert_eq!(serde_json::from_str::<HarnessReadRequestV1>(&encoded).unwrap(), request);
11741    }
11742
11743    /// S10: `g4a_run_finish`'s wire request round-trips, `summary` defaults
11744    /// to `None` for a caller that omits it, and an oversized summary is a
11745    /// named validation failure (`HARNESS_BODY_MAX_BYTES`, the same bound
11746    /// task bodies enforce).
11747    #[test]
11748    fn run_finish_request_round_trips_and_bounds_summary() {
11749        let request = HarnessReadRequestV1::RunFinish {
11750            outcome: HarnessRunFinishOutcomeV1::Done,
11751            summary: Some("handed off cleanly".to_owned()),
11752        };
11753        request.validate().unwrap();
11754        let encoded = serde_json::to_string(&request).unwrap();
11755        assert_eq!(serde_json::from_str::<HarnessReadRequestV1>(&encoded).unwrap(), request);
11756
11757        let without_summary: HarnessReadRequestV1 = serde_json::from_value(serde_json::json!({
11758            "kind": "run-finish",
11759            "outcome": "failed",
11760        })).unwrap();
11761        assert_eq!(
11762            without_summary,
11763            HarnessReadRequestV1::RunFinish { outcome: HarnessRunFinishOutcomeV1::Failed, summary: None },
11764        );
11765        without_summary.validate().unwrap();
11766
11767        let oversized_summary = HarnessReadRequestV1::RunFinish {
11768            outcome: HarnessRunFinishOutcomeV1::Done,
11769            summary: Some("x".repeat(HARNESS_BODY_MAX_BYTES + 1)),
11770        };
11771        assert!(matches!(
11772            oversized_summary.validate(),
11773            Err(HarnessReadApiError::InvalidText("run finish summary")),
11774        ));
11775    }
11776
11777    /// S10: `HarnessRunFinishResultV1` round-trips through the read response
11778    /// wire (`HarnessReadResponseV1::RunFinish`).
11779    #[test]
11780    fn run_finish_result_round_trips_through_the_response_wire() {
11781        let finished = HarnessReadResponseV1::RunFinish(HarnessRunFinishResultV1::Finished {
11782            run_id: HarnessRunId::new(format!("hrun_{}", "d".repeat(24))).unwrap(),
11783            task_id: HarnessTaskId::new(format!("htask_{}", "d".repeat(24))).unwrap(),
11784            result: HarnessRunFinishOutcomeV1::Done,
11785        });
11786        finished.validate().unwrap();
11787        let encoded = serde_json::to_string(&finished).unwrap();
11788        assert_eq!(serde_json::from_str::<HarnessReadResponseV1>(&encoded).unwrap(), finished);
11789
11790        let already_finished = HarnessReadResponseV1::RunFinish(HarnessRunFinishResultV1::AlreadyFinished {
11791            run_id: HarnessRunId::new(format!("hrun_{}", "d".repeat(24))).unwrap(),
11792            lifecycle: HarnessRunLifecycleV1::Failed,
11793        });
11794        already_finished.validate().unwrap();
11795    }
11796
11797    /// D5, Slice D: every `HarnessTaskCreateResultV1`/`HarnessTaskMoveResultV1`
11798    /// variant round-trips through the read response wire
11799    /// (`HarnessReadResponseV1::TaskCreate`/`TaskMove`).
11800    #[test]
11801    fn task_create_and_move_results_round_trip_every_variant() {
11802        let created = HarnessReadResponseV1::TaskCreate(HarnessTaskCreateResultV1::Created {
11803            task_id: HarnessTaskId::new(format!("htask_{}", "c".repeat(24))).unwrap(),
11804            revision: HarnessRevision::new(1).unwrap(),
11805        });
11806        created.validate().unwrap();
11807        let encoded = serde_json::to_string(&created).unwrap();
11808        assert_eq!(serde_json::from_str::<HarnessReadResponseV1>(&encoded).unwrap(), created);
11809
11810        let parent_outside = HarnessReadResponseV1::TaskCreate(
11811            HarnessTaskCreateResultV1::ParentOutsideOwnSubtree {
11812                parent_task_id: HarnessTaskId::new(format!("htask_{}", "d".repeat(24))).unwrap(),
11813                own_task_id: HarnessTaskId::new(format!("htask_{}", "e".repeat(24))).unwrap(),
11814            },
11815        );
11816        parent_outside.validate().unwrap();
11817
11818        let parent_terminal = HarnessReadResponseV1::TaskCreate(HarnessTaskCreateResultV1::ParentTerminal {
11819            parent_task_id: HarnessTaskId::new(format!("htask_{}", "d".repeat(24))).unwrap(),
11820            state: HarnessTaskStateV1::Done,
11821        });
11822        parent_terminal.validate().unwrap();
11823
11824        let title_invalid = HarnessReadResponseV1::TaskCreate(HarnessTaskCreateResultV1::TitleInvalid {
11825            why: "task title is empty, unbounded, padded, or contains control characters".to_owned(),
11826        });
11827        title_invalid.validate().unwrap();
11828
11829        let body_invalid = HarnessReadResponseV1::TaskCreate(HarnessTaskCreateResultV1::BodyInvalid {
11830            why: "task body is unbounded".to_owned(),
11831        });
11832        body_invalid.validate().unwrap();
11833
11834        let dependencies_invalid = HarnessReadResponseV1::TaskCreate(
11835            HarnessTaskCreateResultV1::DependenciesInvalid { why: "dependencies must not self-link".to_owned() },
11836        );
11837        dependencies_invalid.validate().unwrap();
11838
11839        let moved = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::Moved {
11840            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11841            revision: HarnessRevision::new(2).unwrap(),
11842            from: HarnessTaskStateV1::Backlog,
11843            to: HarnessTaskStateV1::Ready,
11844        });
11845        moved.validate().unwrap();
11846        let encoded = serde_json::to_string(&moved).unwrap();
11847        assert_eq!(serde_json::from_str::<HarnessReadResponseV1>(&encoded).unwrap(), moved);
11848
11849        let task_is_own = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::TaskIsOwn {
11850            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11851        });
11852        task_is_own.validate().unwrap();
11853
11854        let outside_subtree = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::TaskOutsideOwnSubtree {
11855            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11856            own_task_id: HarnessTaskId::new(format!("htask_{}", "0".repeat(24))).unwrap(),
11857        });
11858        outside_subtree.validate().unwrap();
11859
11860        let illegal_transition = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::IllegalTransition {
11861            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11862            from: HarnessTaskStateV1::Done,
11863            to: HarnessTaskStateV1::Ready,
11864        });
11865        illegal_transition.validate().unwrap();
11866
11867        let revision_conflict = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::RevisionConflict {
11868            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11869            expected: HarnessRevision::new(2).unwrap(),
11870            current: HarnessRevision::new(3).unwrap(),
11871        });
11872        revision_conflict.validate().unwrap();
11873    }
11874
11875    fn base_session_context() -> SessionContextV1 {
11876        SessionContextV1 {
11877            grant_id: SessionGrantId::new(format!("hgrant_{}", "a".repeat(24))).unwrap(),
11878            grant_revision: HarnessRevision::new(1).unwrap(),
11879            actor_run: CallerRunV1 {
11880                run_id: HarnessRunId::new(format!("hrun_{}", "a".repeat(24))).unwrap(),
11881                task_id: None,
11882                parent_run_id: None,
11883                lifecycle: HarnessRunLifecycleV1::Running,
11884                references_redacted: false,
11885            },
11886            task: None,
11887            sibling_runs: Vec::new(),
11888            read_permissions: HarnessReadPermissionsV1 {
11889                tasks: HarnessEntityReadScopeV1::None,
11890                runs: HarnessEntityReadScopeV1::None,
11891                operations: HarnessEntityReadScopeV1::None,
11892            },
11893            monitoring_visibility: HarnessMonitoringVisibilityV1::None,
11894            child_task_count: 0,
11895            child_task_subtree_depth: 0,
11896            task_create: false,
11897            task_mutate: false,
11898            allowed_tool_ids: vec!["g4a_context_get".to_owned(), "g4a_run_finish".to_owned()],
11899            history_message_count: None,
11900            completed_turn_count: None,
11901            total_tokens: None,
11902        }
11903    }
11904
11905    /// D5, Slice D: `expected_allowed_tool_ids` derives `g4a_task_create`/
11906    /// `g4a_task_move` from `task_permissions.create`/`.mutate` -- create-only
11907    /// advertises `g4a_task_create` alone, and a grant cannot claim a tool it
11908    /// was not actually granted (`InvalidAllowedTools`).
11909    #[test]
11910    fn expected_allowed_tool_ids_derives_task_tools_from_task_permissions() {
11911        let base = base_session_context();
11912        base.validate().unwrap();
11913
11914        let create_only = SessionContextV1 {
11915            task_create: true,
11916            allowed_tool_ids: vec![
11917                "g4a_context_get".to_owned(),
11918                "g4a_run_finish".to_owned(),
11919                "g4a_task_create".to_owned(),
11920            ],
11921            ..base.clone()
11922        };
11923        create_only.validate().unwrap();
11924
11925        let both = SessionContextV1 {
11926            task_create: true,
11927            task_mutate: true,
11928            allowed_tool_ids: vec![
11929                "g4a_context_get".to_owned(),
11930                "g4a_run_finish".to_owned(),
11931                "g4a_task_create".to_owned(),
11932                "g4a_task_move".to_owned(),
11933            ],
11934            ..base.clone()
11935        };
11936        both.validate().unwrap();
11937
11938        let create_only_claims_move = SessionContextV1 {
11939            task_create: true,
11940            allowed_tool_ids: vec![
11941                "g4a_context_get".to_owned(),
11942                "g4a_task_create".to_owned(),
11943                "g4a_task_move".to_owned(),
11944            ],
11945            ..base
11946        };
11947        assert!(matches!(
11948            create_only_claims_move.validate(),
11949            Err(HarnessReadApiError::InvalidAllowedTools),
11950        ));
11951    }
11952
11953    /// The removed `maximum_child_count`/`maximum_child_depth` wire fields
11954    /// stated a cap that was checked for range but never compared against an
11955    /// actual child count anywhere -- this proves their replacements
11956    /// (`child_task_count`/`child_task_subtree_depth`) carry whatever the
11957    /// engine observed, with no ceiling of their own: a value far past the
11958    /// old `HARNESS_CHILD_COUNT_MAX`/`HARNESS_CHILD_DEPTH_MAX` bounds still
11959    /// validates and round-trips over the wire under the new field names.
11960    #[test]
11961    fn child_task_observations_carry_unbounded_values() {
11962        let base = base_session_context();
11963        let many_children = SessionContextV1 {
11964            child_task_count: u64::from(HARNESS_CHILD_COUNT_MAX) * 1_000,
11965            child_task_subtree_depth: u64::from(HARNESS_CHILD_DEPTH_MAX) * 1_000,
11966            ..base
11967        };
11968        many_children.validate().unwrap();
11969        let encoded = serde_json::to_value(&many_children).unwrap();
11970        assert_eq!(
11971            encoded.get("child_task_count").and_then(serde_json::Value::as_u64),
11972            Some(u64::from(HARNESS_CHILD_COUNT_MAX) * 1_000),
11973        );
11974        assert_eq!(
11975            encoded.get("child_task_subtree_depth").and_then(serde_json::Value::as_u64),
11976            Some(u64::from(HARNESS_CHILD_DEPTH_MAX) * 1_000),
11977        );
11978        assert!(encoded.get("maximum_child_count").is_none());
11979        assert!(encoded.get("maximum_child_depth").is_none());
11980        let decoded: SessionContextV1 = serde_json::from_value(encoded).unwrap();
11981        assert_eq!(decoded, many_children);
11982    }
11983}