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        machine: None,}
8575    }
8576
8577    /// Regression for `ed4fa61`: the producer (`SlimNodeInventory::
8578    /// from_snapshot`/`from_c2_snapshot`, c2-protocol) orders a managed-
8579    /// session page by `managed_session_liveness_rank` (Live, IdentityPending,
8580    /// Dormant, Unavailable) first and `record_id` only within a rank, so a
8581    /// real page's `record_id`s are routinely NOT ascending overall. Before
8582    /// this fix `validate` demanded flat ascending `record_id` order and
8583    /// rejected every such page outright.
8584    #[test]
8585    fn managed_session_ordering_is_liveness_rank_then_record_id() {
8586        let mut live = sample_managed_session("zzz-live");
8587        live.state = HarnessRuntimeManagedStateV1::Live;
8588        let mut pending = sample_managed_session("aaa-pending");
8589        pending.state = HarnessRuntimeManagedStateV1::IdentityPending;
8590        let mut unavailable = sample_managed_session("mmm-unavailable");
8591        unavailable.state = HarnessRuntimeManagedStateV1::Unavailable;
8592
8593        let ordered = sample_runtime_inventory(vec![
8594            live.clone(),
8595            pending,
8596            unavailable,
8597        ]);
8598        ordered.validate().expect(
8599            "liveness-rank-then-record_id order validates even though record_id is not ascending overall",
8600        );
8601
8602        let duplicate = sample_runtime_inventory(vec![live.clone(), live.clone()]);
8603        assert!(duplicate.validate().is_err());
8604
8605        let mut dormant = sample_managed_session("aaa-dormant");
8606        dormant.state = HarnessRuntimeManagedStateV1::Dormant;
8607        let rank_out_of_order = sample_runtime_inventory(vec![dormant, live]);
8608        assert!(rank_out_of_order.validate().is_err());
8609    }
8610
8611    /// Slice R (`hatchery-node`'s session-record retention sweep):
8612    /// `retired_count` is additive, defaults to `0` for a pre-existing
8613    /// payload that never had it, and round-trips intact once a producer
8614    /// sets a real, nonzero value.
8615    #[test]
8616    fn runtime_inventory_retired_count_round_trips_and_defaults_to_zero() {
8617        let mut inventory = sample_runtime_inventory(Vec::new());
8618        assert_eq!(inventory.retired_count, 0);
8619        inventory.validate().expect("a fresh sample inventory is valid");
8620
8621        inventory.retired_count = 5;
8622        let encoded = serde_json::to_string(&inventory).unwrap();
8623        assert!(encoded.contains("\"retired_count\":5"));
8624        let decoded: HarnessRuntimeInventoryV1 = serde_json::from_str(&encoded).unwrap();
8625        assert_eq!(decoded, inventory);
8626        decoded.validate().expect("a nonzero retired_count is still a valid inventory");
8627    }
8628
8629    #[test]
8630    fn runtime_inventory_launch_inventory_round_trips_and_fails_closed() {
8631        let node = HarnessRuntimeNodeInventoryV1 {
8632            node_id: "node-a".to_owned(),
8633            incarnation_id: "07".repeat(16),
8634            observed_at_unix_ms: 1_000,
8635            event_sequence: 4,
8636            inventory: HarnessRuntimeInventoryV1 {
8637                enabled_providers: vec!["claude".to_owned()],
8638                workspaces: BTreeMap::new(),
8639                workspace_count: 0,
8640                workspaces_truncated: false,
8641                session_count: 0,
8642                sessions_truncated: false,
8643                managed_sessions: Vec::new(),
8644                managed_session_count: 0,
8645                managed_sessions_truncated: false,
8646                retired_count: 0,
8647                launch_inventory: Some(HarnessRuntimeLaunchInventoryV1 {
8648                    spawn_profiles: Some(vec![HarnessRuntimeSpawnProfileSummaryV1 {
8649                        id: "default".to_owned(),
8650                        revision: "rev-1".to_owned(),
8651                        environment_profile: Some(HarnessRuntimeEnvironmentProfileReceiptV1 {
8652                            profile_id: "env-a".to_owned(),
8653                            profile_revision: "env-rev-1".to_owned(),
8654
8655                        machine: None,}),
8656                    }]),
8657                    bundles: Some(vec![HarnessRuntimeBundleReceiptV1 {
8658                        id: "bundle-a".to_owned(),
8659                        revision: "bundle-rev-1".to_owned(),
8660                        digest: format!("sha256:{}", "a".repeat(64)),
8661                    }]),
8662                }),
8663            },
8664        };
8665        node.validate().expect("valid launch inventory");
8666        let encoded = serde_json::to_string(&node).unwrap();
8667        let decoded: HarnessRuntimeNodeInventoryV1 = serde_json::from_str(&encoded).unwrap();
8668        assert_eq!(decoded, node);
8669
8670        let mut missing_component = node.clone();
8671        missing_component.inventory.launch_inventory = Some(HarnessRuntimeLaunchInventoryV1 {
8672            spawn_profiles: None,
8673            bundles: None,
8674        });
8675        assert!(missing_component.validate().is_err());
8676
8677        let mut duplicate_profile = node.clone();
8678        let profile = duplicate_profile.inventory.launch_inventory.as_ref().unwrap()
8679            .spawn_profiles.as_ref().unwrap()[0].clone();
8680        duplicate_profile.inventory.launch_inventory.as_mut().unwrap()
8681            .spawn_profiles.as_mut().unwrap().push(profile);
8682        assert!(duplicate_profile.validate().is_err());
8683
8684        let mut bad_digest = node.clone();
8685        bad_digest.inventory.launch_inventory.as_mut().unwrap()
8686            .bundles.as_mut().unwrap()[0].digest = "not-a-digest".to_owned();
8687        assert!(bad_digest.validate().is_err());
8688
8689        let mut no_launch_inventory = node;
8690        no_launch_inventory.inventory.launch_inventory = None;
8691        no_launch_inventory.validate().expect("absent launch inventory stays valid");
8692    }
8693
8694    #[test]
8695    fn native_history_is_exact_bounded_and_excludes_sensitive_fields() {
8696        let credential = HarnessOperatorCredential::parse(format!(
8697            "g4aho_{}",
8698            "a".repeat(64),
8699        )).unwrap();
8700        let route = HarnessNativeSessionRouteV1 {
8701            node_id: "node-a".to_owned(),
8702            incarnation_id: "1".repeat(32),
8703            scope: HarnessNativeSessionCatalogScopeV1::Workspace,
8704            workspace_id: Some("workspace-a".to_owned()),
8705            provider: "codex".to_owned(),
8706        };
8707        let selection = HarnessNativeSessionSelectionV1 {
8708            route: route.clone(),
8709            catalog_revision: 7,
8710            recent_cutoff_unix_ms: 9,
8711            selection_id: "selection-a".to_owned(),
8712        };
8713        for request in [
8714            HarnessOperatorRequestV1::CatalogNativeSessions {
8715                route: route.clone(),
8716                limit: HARNESS_NATIVE_SESSION_CATALOG_LIMIT_MAX,
8717            },
8718            HarnessOperatorRequestV1::PageNativeSessions {
8719                route: route.clone(),
8720                window: HarnessNativeSessionCatalogWindowV1::Recent,
8721                catalog_revision: 7,
8722                recent_cutoff_unix_ms: 9,
8723                after_selection_id: Some("selection-a".to_owned()),
8724                limit: 1,
8725            },
8726            HarnessOperatorRequestV1::PreviewNativeSession {
8727                selection: selection.clone(),
8728                message_limit: HARNESS_NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX,
8729            },
8730        ] {
8731            HarnessOperatorEnvelopeV1 {
8732                build_stamp: BUILD_STAMP.to_string(),
8733                credential: credential.clone(),
8734                request,
8735            }.validate().unwrap();
8736        }
8737        let response = HarnessOperatorResponseV1::NativeSessionPreviewed(
8738            HarnessNativeSessionPreviewedV1 {
8739                selection,
8740                preview: HarnessNativeSessionPreviewV1 {
8741                    title: Some("Bounded preview".to_owned()),
8742                    modified_at_unix_ms: Some(10),
8743                    model: Some("model-a".to_owned()),
8744                    message_count: 1,
8745                    message_count_exact: true,
8746                    completed_turn_count: Some(1),
8747                    total_tokens: Some(12),
8748                    truncated: false,
8749                    messages: vec![HarnessNativeSessionPreviewMessageV1 {
8750                        role: HarnessNativeSessionPreviewRoleV1::User,
8751                        text: "bounded history preview".to_owned(),
8752                    }],
8753                },
8754            },
8755        );
8756        response.validate().unwrap();
8757        let encoded = serde_json::to_string(&response).unwrap();
8758        for forbidden in [
8759            "g4aho_",
8760            "credential",
8761            "provider_identity",
8762            "session_id",
8763            "terminal",
8764            "cwd",
8765        ] {
8766            assert!(!encoded.contains(forbidden));
8767        }
8768    }
8769
8770    #[test]
8771    fn unknown_request_fields_and_unbounded_limits_fail_closed() {
8772        let unknown = r#"{"kind":"tasks-list","limit":10,"cursor":"secret"}"#;
8773        assert!(serde_json::from_str::<HarnessReadRequestV1>(unknown).is_err());
8774        assert!(HarnessReadRequestV1::TasksList {
8775            after_task_id: None, state: None, parent_task_id: None, limit: 0,
8776        }.validate().is_err());
8777        assert!(HarnessReadRequestV1::TasksList {
8778            after_task_id: None, state: None, parent_task_id: None, limit: 65,
8779        }.validate().is_err());
8780    }
8781
8782    #[test]
8783    fn redacted_cross_entity_references_do_not_require_hidden_ids() {
8784        let redacted_run = r#"{
8785            "run_id":"hrun_000000000000000000000001",
8786            "revision":1,
8787            "parent_run_id":null,
8788            "task_id":null,
8789            "operation_id":null,
8790            "intent":{"mode":"inline","worktree":"existing","has_delivery_bundle":false,"has_continuation":false},
8791            "lifecycle":"requested",
8792            "binding":"none",
8793            "result_disposition":null,
8794            "failure_category":null,
8795            "delivery_receipt":null,
8796            "references_redacted":true,
8797            "created_at_unix_ms":1,
8798            "updated_at_unix_ms":1
8799        }"#;
8800        let run: RedactedRunV1 = serde_json::from_str(redacted_run).expect("redacted run");
8801        run.validate().expect("valid redacted run");
8802
8803        let operation_with_grant = r#"{
8804            "operation_id":"hop_000000000000000000000001",
8805            "revision":1,
8806            "kind":"create-task",
8807            "state":"prepared",
8808            "task_id":null,
8809            "run_id":null,
8810            "grant_id":"hgrant_000000000000000000000001",
8811            "reconciles_operation_id":null,
8812            "references_redacted":true,
8813            "failure_category":null,
8814            "outcome_unknown_reason":null,
8815            "reconciliation_outcome":null,
8816            "created_at_unix_ms":1,
8817            "updated_at_unix_ms":1,
8818            "dispatched_at_unix_ms":null,
8819            "finished_at_unix_ms":null
8820        }"#;
8821        assert!(serde_json::from_str::<RedactedOperationV1>(operation_with_grant).is_err());
8822    }
8823
8824    #[test]
8825    fn operator_run_workspace_requests_are_exact_harness_only_round_trips() {
8826        let run_id = run_workspace_origin().run_id;
8827        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
8828        let object_id = HarnessGitObjectIdV1::new("a".repeat(40)).unwrap();
8829        let requests = vec![
8830            HarnessOperatorRequestV1::InspectRunWorkspace {
8831                run_id: run_id.clone(),
8832            },
8833            HarnessOperatorRequestV1::ReadRunWorkspaceFile {
8834                run_id: run_id.clone(),
8835                path: path.clone(),
8836            },
8837            HarnessOperatorRequestV1::ReadRunGitHistory {
8838                run_id: run_id.clone(),
8839                path: Some(path.clone()),
8840                before: Some(object_id.clone()),
8841                limit: HARNESS_GIT_HISTORY_LIMIT_MAX,
8842            },
8843            HarnessOperatorRequestV1::ReadRunGitDiff {
8844                run_id,
8845                mode: HarnessGitDiffModeV1::Commit { revision: object_id },
8846                path: Some(path),
8847            },
8848        ];
8849        for request in requests {
8850            request.validate().expect("valid workspace request");
8851            let encoded = serde_json::to_string(&request).unwrap();
8852            for forbidden in [
8853                "node_id",
8854                "workspace_id",
8855                "endpoint",
8856                "root",
8857                "workspace_root",
8858                "worktree",
8859                "worktree_path",
8860                "environment",
8861                "spawn_spec",
8862                "provider_home",
8863            ] {
8864                assert!(!encoded.contains(forbidden), "request exposed {forbidden}");
8865            }
8866            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
8867            assert_eq!(decoded, request);
8868        }
8869    }
8870
8871    #[test]
8872    fn operator_v4_workspace_replies_round_trip_without_host_or_diagnostic_fields() {
8873        let origin = run_workspace_origin();
8874        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
8875        let commit = git_commit('a');
8876        let responses = vec![
8877            HarnessOperatorResponseV1::RunWorkspaceInspected(
8878                HarnessRunWorkspaceInspectionV1 {
8879                    origin: origin.clone(),
8880                    entries: vec![HarnessWorkspaceTreeEntryV1 {
8881                        relative_path: path.clone(),
8882                        kind: HarnessWorkspaceEntryKindV1::File,
8883                    }],
8884                    tree_truncated: false,
8885                    git: HarnessGitSummaryV1 {
8886                        is_repository: true,
8887                        branch: Some("main".to_owned()),
8888                        status: vec![HarnessGitStatusEntryV1 {
8889                            index_status: HarnessGitStatusCodeV1::Unmodified,
8890                            worktree_status: HarnessGitStatusCodeV1::Modified,
8891                            path: path.clone(),
8892                            previous_path: None,
8893                        }],
8894                        recent_commits: vec![HarnessGitCommitSummaryV1 {
8895                            id: commit.id.clone(),
8896                            summary: commit.subject.clone(),
8897                        }],
8898                        truncated: false,
8899                    },
8900                    truncation: None,
8901                },
8902            ),
8903            HarnessOperatorResponseV1::RunWorkspaceFileRead(HarnessRunWorkspaceFileV1 {
8904                origin: origin.clone(),
8905                path: path.clone(),
8906                content: HarnessWorkspaceFileContentV1::Utf8 {
8907                    text: "fn main() {}\n".to_owned(),
8908                    byte_len: 13,
8909                },
8910                revision: Some(HarnessWorkspaceFileRevisionV1::new("b".repeat(64)).unwrap()),
8911            }),
8912            HarnessOperatorResponseV1::RunGitHistoryRead(HarnessRunGitHistoryPageV1 {
8913                origin: origin.clone(),
8914                path: Some(path.clone()),
8915                commits: vec![commit],
8916                next_before: None,
8917                truncated: false,
8918            }),
8919            HarnessOperatorResponseV1::RunGitDiffRead(HarnessRunGitDiffV1 {
8920                origin,
8921                mode: HarnessGitDiffModeV1::Working,
8922                path: Some(path),
8923                text: "diff --git a/src/lib.rs b/src/lib.rs\n".to_owned(),
8924                truncated: false,
8925            }),
8926        ];
8927        for response in responses {
8928            response.validate().expect("valid V4 workspace response");
8929            let encoded = serde_json::to_string(&response).unwrap();
8930            assert_json_has_no_forbidden_keys(&encoded, &[
8931                "root",
8932                "worktree",
8933                "endpoint",
8934                "diagnostic",
8935                "author_email",
8936                "committer_email",
8937                "environment",
8938                "spawn_spec",
8939                "provider_home",
8940            ]);
8941            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
8942            assert_eq!(decoded, response);
8943        }
8944    }
8945
8946    #[test]
8947    fn operator_v4_repository_paths_and_git_cursors_fail_closed() {
8948        for value in [
8949            "",
8950            ".",
8951            "..",
8952            "../secret",
8953            "src/../secret",
8954            "src//lib.rs",
8955            "/absolute",
8956            "C:/absolute",
8957            r"src\lib.rs",
8958            "src/\nlib.rs",
8959        ] {
8960            assert!(HarnessRepositoryPathV1::new(value).is_err(), "accepted {value:?}");
8961        }
8962        assert!(HarnessRepositoryPathV1::new("x".repeat(HARNESS_REPOSITORY_PATH_MAX_BYTES)).is_ok());
8963        assert!(HarnessRepositoryPathV1::new("x".repeat(HARNESS_REPOSITORY_PATH_MAX_BYTES + 1)).is_err());
8964        assert!(HarnessGitObjectIdV1::new("a".repeat(40)).is_ok());
8965        assert!(HarnessGitObjectIdV1::new("a".repeat(64)).is_ok());
8966        assert!(HarnessGitObjectIdV1::new("A".repeat(40)).is_err());
8967        assert!(HarnessGitObjectIdV1::new("a".repeat(39)).is_err());
8968
8969        let unknown_route = format!(
8970            r#"{{"kind":"read-run-workspace-file","run_id":"hrun_{}","path":"src/lib.rs","workspace_id":"forbidden"}}"#,
8971            "a".repeat(24),
8972        );
8973        assert!(serde_json::from_str::<HarnessOperatorRequestV1>(&unknown_route).is_err());
8974    }
8975
8976    #[test]
8977    fn operator_v4_workspace_payloads_enforce_exact_bounds() {
8978        let origin = run_workspace_origin();
8979        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
8980        let at_file_limit = HarnessWorkspaceFileContentV1::Utf8 {
8981            text: "x".repeat(HARNESS_WORKSPACE_FILE_MAX_BYTES),
8982            byte_len: HARNESS_WORKSPACE_FILE_MAX_BYTES as u32,
8983        };
8984        assert!(at_file_limit.validate().is_ok());
8985        assert!(HarnessWorkspaceFileContentV1::Utf8 {
8986            text: "x".repeat(HARNESS_WORKSPACE_FILE_MAX_BYTES + 1),
8987            byte_len: (HARNESS_WORKSPACE_FILE_MAX_BYTES + 1) as u32,
8988        }.validate().is_err());
8989        assert!(HarnessRunGitDiffV1 {
8990            origin: origin.clone(),
8991            mode: HarnessGitDiffModeV1::Working,
8992            path: Some(path.clone()),
8993            text: "x".repeat(HARNESS_GIT_DIFF_MAX_BYTES),
8994            truncated: false,
8995        }.validate().is_ok());
8996        assert!(HarnessRunGitDiffV1 {
8997            origin: origin.clone(),
8998            mode: HarnessGitDiffModeV1::Working,
8999            path: Some(path.clone()),
9000            text: "x".repeat(HARNESS_GIT_DIFF_MAX_BYTES + 1),
9001            truncated: true,
9002        }.validate().is_err());
9003        assert!(HarnessOperatorRequestV1::ReadRunGitHistory {
9004            run_id: origin.run_id.clone(),
9005            path: Some(path.clone()),
9006            before: None,
9007            limit: HARNESS_GIT_HISTORY_LIMIT_MAX + 1,
9008        }.validate().is_err());
9009
9010        let entries: Vec<_> = (0..HARNESS_WORKSPACE_TREE_ENTRIES_MAX)
9011            .map(|index| HarnessWorkspaceTreeEntryV1 {
9012                relative_path: HarnessRepositoryPathV1::new(format!("entry-{index:04}")).unwrap(),
9013                kind: HarnessWorkspaceEntryKindV1::File,
9014            })
9015            .collect();
9016        let empty_git = HarnessGitSummaryV1 {
9017            is_repository: false,
9018            branch: None,
9019            status: Vec::new(),
9020            recent_commits: Vec::new(),
9021            truncated: false,
9022        };
9023        assert!(HarnessRunWorkspaceInspectionV1 {
9024            origin: origin.clone(),
9025            entries: entries.clone(),
9026            tree_truncated: false,
9027            git: empty_git.clone(),
9028            truncation: None,
9029        }.validate().is_ok());
9030        let mut too_many_entries = entries;
9031        too_many_entries.push(HarnessWorkspaceTreeEntryV1 {
9032            relative_path: HarnessRepositoryPathV1::new("entry-0512").unwrap(),
9033            kind: HarnessWorkspaceEntryKindV1::File,
9034        });
9035        assert!(HarnessRunWorkspaceInspectionV1 {
9036            origin: origin.clone(),
9037            entries: too_many_entries,
9038            tree_truncated: true,
9039            git: empty_git,
9040            truncation: None,
9041        }.validate().is_err());
9042
9043        let status: Vec<_> = (0..HARNESS_GIT_STATUS_ENTRIES_MAX)
9044            .map(|index| HarnessGitStatusEntryV1 {
9045                index_status: HarnessGitStatusCodeV1::Unmodified,
9046                worktree_status: HarnessGitStatusCodeV1::Modified,
9047                path: HarnessRepositoryPathV1::new(format!("status-{index:04}")).unwrap(),
9048                previous_path: None,
9049            })
9050            .collect();
9051        let recent_commits: Vec<_> = (0..HARNESS_GIT_RECENT_COMMITS_MAX)
9052            .map(|index| HarnessGitCommitSummaryV1 {
9053                id: HarnessGitObjectIdV1::new(format!("{index:040x}")).unwrap(),
9054                summary: format!("commit {index}"),
9055            })
9056            .collect();
9057        assert!(HarnessGitSummaryV1 {
9058            is_repository: true,
9059            branch: Some("main".to_owned()),
9060            status: status.clone(),
9061            recent_commits: recent_commits.clone(),
9062            truncated: false,
9063        }.validate().is_ok());
9064        let mut too_many_status = status;
9065        too_many_status.push(HarnessGitStatusEntryV1 {
9066            index_status: HarnessGitStatusCodeV1::Unmodified,
9067            worktree_status: HarnessGitStatusCodeV1::Modified,
9068            path: HarnessRepositoryPathV1::new("status-0128").unwrap(),
9069            previous_path: None,
9070        });
9071        assert!(HarnessGitSummaryV1 {
9072            is_repository: true,
9073            branch: Some("main".to_owned()),
9074            status: too_many_status,
9075            recent_commits: Vec::new(),
9076            truncated: true,
9077        }.validate().is_err());
9078        let mut too_many_recent = recent_commits;
9079        too_many_recent.push(HarnessGitCommitSummaryV1 {
9080            id: HarnessGitObjectIdV1::new(format!("{:040x}", HARNESS_GIT_RECENT_COMMITS_MAX)).unwrap(),
9081            summary: "one too many".to_owned(),
9082        });
9083        assert!(HarnessGitSummaryV1 {
9084            is_repository: true,
9085            branch: Some("main".to_owned()),
9086            status: Vec::new(),
9087            recent_commits: too_many_recent,
9088            truncated: true,
9089        }.validate().is_err());
9090
9091        let commits: Vec<_> = (0..HARNESS_GIT_HISTORY_LIMIT_MAX)
9092            .map(|index| {
9093                let mut commit = git_commit('a');
9094                commit.id = HarnessGitObjectIdV1::new(format!("{index:040x}")).unwrap();
9095                commit
9096            })
9097            .collect();
9098        assert!(HarnessRunGitHistoryPageV1 {
9099            origin: origin.clone(),
9100            path: Some(path.clone()),
9101            commits: commits.clone(),
9102            next_before: None,
9103            truncated: false,
9104        }.validate().is_ok());
9105        let next_before = commits.last().map(|commit| commit.id.clone());
9106        assert!(HarnessRunGitHistoryPageV1 {
9107            origin: origin.clone(),
9108            path: Some(path.clone()),
9109            commits: commits.clone(),
9110            next_before: next_before.clone(),
9111            truncated: false,
9112        }.validate().is_ok());
9113        assert!(HarnessRunGitHistoryPageV1 {
9114            origin: origin.clone(),
9115            path: Some(path.clone()),
9116            commits: commits.clone(),
9117            next_before: None,
9118            truncated: true,
9119        }.validate().is_ok());
9120        assert!(HarnessRunGitHistoryPageV1 {
9121            origin: origin.clone(),
9122            path: Some(path.clone()),
9123            commits: commits.clone(),
9124            next_before,
9125            truncated: true,
9126        }.validate().is_ok());
9127        let mut too_many_commits = commits;
9128        let mut extra_commit = git_commit('a');
9129        extra_commit.id = HarnessGitObjectIdV1::new(format!("{:040x}", HARNESS_GIT_HISTORY_LIMIT_MAX)).unwrap();
9130        too_many_commits.push(extra_commit);
9131        assert!(HarnessRunGitHistoryPageV1 {
9132            origin,
9133            path: Some(path),
9134            commits: too_many_commits,
9135            next_before: None,
9136            truncated: false,
9137        }.validate().is_err());
9138    }
9139
9140    fn monitor_with_mixed_capabilities() -> SessionMonitorV1 {
9141        SessionMonitorV1 {
9142            run_id: HarnessRunId::new("hrun_000000000000000000000001").unwrap(),
9143            visibility: HarnessMonitoringVisibilityV1::Detail,
9144            availability: ProjectionAvailabilityV1::Frozen,
9145            freshness: ProjectionFreshnessV1::ReplacedIncarnation,
9146            transport_incomplete: false,
9147            features: MonitorFeatureStatesV1 {
9148                todo: FeatureObservationStateV1::Observed,
9149                tools: FeatureObservationStateV1::NotSupportedByObservedSources,
9150                subagents: FeatureObservationStateV1::Unknown,
9151                interactions: FeatureObservationStateV1::SupportedNotObserved,
9152                owned_processes: FeatureObservationStateV1::Observed,
9153                files: FeatureObservationStateV1::Observed,
9154                usage: FeatureObservationStateV1::SupportedNotObserved,
9155                history: FeatureObservationStateV1::Unknown,
9156            },
9157            todo_total: 1,
9158            todo_completed: 0,
9159            active_tools: 0,
9160            active_subagents: 0,
9161            active_interactions: 0,
9162            active_blocks: 0,
9163            active_processes: 0,
9164            input_tokens: 0,
9165            output_tokens: 0,
9166            cache_read_tokens: 0,
9167            cache_write_tokens: 0,
9168            reasoning_tokens: 0,
9169            context_window_tokens: None,
9170            history: None,
9171            detail: Some(SessionMonitorDetailV1 {
9172                todo_facts: vec![TodoFactV1 {
9173                    state: TodoStateV1::Unknown,
9174                    todo_id: Some("todo-1".to_owned()),
9175                    label: Some("review result".to_owned()),
9176                    evidence: ObservationEvidenceV1::PtyHint,
9177                }],
9178                tool_facts: Vec::new(),
9179                subagent_facts: Vec::new(),
9180                interaction_facts: Vec::new(),
9181                block_facts: Vec::new(),
9182                process_facts: vec![ActivityFactV1 {
9183                    class: ActivityClassV1::OwnedProcess,
9184                    state: ActivityStateV1::UnknownAfterGap,
9185                    label: Some("compiler".to_owned()),
9186                    correlation: Some(1),
9187                    evidence: ObservationEvidenceV1::ManagedHook,
9188                }],
9189                file_facts: vec![FileFactV1 {
9190                    action: FileActionV1::Changed,
9191                    relative_path: Some("src/lib.rs".to_owned()),
9192                    evidence: ObservationEvidenceV1::StructuredProvider,
9193                }],
9194            }),
9195        }
9196    }
9197
9198    #[test]
9199    fn monitor_preserves_mixed_capability_and_frozen_replaced_truth() {
9200        let monitor = monitor_with_mixed_capabilities();
9201        monitor.validate().expect("truthful mixed monitor");
9202        let encoded = serde_json::to_string(&monitor).expect("encode monitor");
9203        let decoded: SessionMonitorV1 = serde_json::from_str(&encoded).expect("decode monitor");
9204        assert_eq!(decoded, monitor);
9205        assert_eq!(decoded.availability, ProjectionAvailabilityV1::Frozen);
9206        assert_eq!(decoded.freshness, ProjectionFreshnessV1::ReplacedIncarnation);
9207        assert_eq!(decoded.features.tools, FeatureObservationStateV1::NotSupportedByObservedSources);
9208        let detail = decoded.detail.unwrap();
9209        assert_eq!(detail.todo_facts[0].state, TodoStateV1::Unknown);
9210        assert_eq!(detail.todo_facts[0].todo_id.as_deref(), Some("todo-1"));
9211        assert_eq!(detail.todo_facts[0].label.as_deref(), Some("review result"));
9212        assert_eq!(detail.process_facts[0].correlation, Some(1));
9213        assert_eq!(detail.file_facts[0].relative_path.as_deref(), Some("src/lib.rs"));
9214    }
9215
9216    #[test]
9217    fn monitor_rejects_inference_and_raw_detail_sentinels() {
9218        let mut monitor = monitor_with_mixed_capabilities();
9219        monitor.detail.as_mut().unwrap().tool_facts.push(ActivityFactV1 {
9220            class: ActivityClassV1::Tool,
9221            state: ActivityStateV1::Active,
9222            label: Some("command".to_owned()),
9223            correlation: Some(1),
9224            evidence: ObservationEvidenceV1::StructuredProvider,
9225        });
9226        assert!(monitor.validate().is_err());
9227
9228        let raw_file = r#"{"action":"changed","evidence":"managed-hook","path":"C:\\private\\prompt.txt"}"#;
9229        assert!(serde_json::from_str::<FileFactV1>(raw_file).is_err());
9230        let encoded = serde_json::to_string(&monitor_with_mixed_capabilities()).unwrap();
9231        for sentinel in ["private", "prompt.txt", "provider_id", "correlation_id"] {
9232            assert!(!encoded.contains(sentinel));
9233        }
9234
9235        let mut partial = monitor_with_mixed_capabilities();
9236        partial.availability = ProjectionAvailabilityV1::Partial;
9237        partial.freshness = ProjectionFreshnessV1::IncompleteAfterGap;
9238        partial.transport_incomplete = true;
9239        partial.validate().expect("truthful partial gap");
9240    }
9241
9242    #[test]
9243    fn timeline_structured_fields_round_trip_without_raw_correlation_shape() {
9244        let run_id = HarnessRunId::new("hrun_000000000000000000000001").unwrap();
9245        let page = TimelinePageV1 {
9246            run_id: run_id.clone(),
9247            availability: ProjectionAvailabilityV1::Current,
9248            freshness: ProjectionFreshnessV1::Live,
9249            transport_incomplete: false,
9250            entries: vec![TimelineEntryV1 {
9251                sequence: 7,
9252                received_at_ms: 10,
9253                category: TimelineCategoryV1::Tool,
9254                label: Some("command".to_owned()),
9255                state: TimelineStateV1::Completed,
9256                correlation: Some(1),
9257                evidence: ObservationEvidenceV1::StructuredProvider,
9258            }],
9259            next_cursor: None,
9260        };
9261        page.validate_for(&run_id).unwrap();
9262        let encoded = serde_json::to_string(&page).unwrap();
9263        assert!(encoded.contains("\"label\":\"command\""));
9264        assert!(encoded.contains("\"state\":\"completed\""));
9265        assert!(encoded.contains("\"correlation\":1"));
9266        assert!(!encoded.contains("correlation_id"));
9267        assert_eq!(serde_json::from_str::<TimelinePageV1>(&encoded).unwrap(), page);
9268
9269        let old_entry: TimelineEntryV1 = serde_json::from_str(
9270            r#"{"sequence":1,"received_at_ms":2,"category":"usage","evidence":"managed-hook"}"#,
9271        ).unwrap();
9272        assert_eq!(old_entry.state, TimelineStateV1::Unknown);
9273        assert_eq!(old_entry.correlation, None);
9274        old_entry.validate().unwrap();
9275    }
9276
9277    #[test]
9278    fn monitor_history_summary_matches_categorical_feature_state() {
9279        let mut monitor = monitor_with_mixed_capabilities();
9280        monitor.features.history = FeatureObservationStateV1::Observed;
9281        monitor.history = Some(SessionMonitorHistoryV1 {
9282            message_count: 12,
9283            message_count_exact: true,
9284            completed_turn_count: Some(3),
9285            total_tokens: Some(800),
9286        });
9287        monitor.validate().unwrap();
9288        let encoded = serde_json::to_string(&monitor).unwrap();
9289        let decoded: SessionMonitorV1 = serde_json::from_str(&encoded).unwrap();
9290        assert_eq!(decoded.history, monitor.history);
9291
9292        monitor.history = None;
9293        monitor.validate().expect("redacted observed history remains valid");
9294        monitor.features.history = FeatureObservationStateV1::NotSupportedByObservedSources;
9295        monitor.history = Some(SessionMonitorHistoryV1 {
9296            message_count: 1,
9297            message_count_exact: false,
9298            completed_turn_count: None,
9299            total_tokens: None,
9300        });
9301        assert!(monitor.validate().is_err());
9302    }
9303
9304    #[test]
9305    fn structured_file_fact_rejects_absolute_or_traversing_paths() {
9306        for relative_path in ["C:/private.txt", "/private.txt", "src/../private.txt", "src\\private.txt"] {
9307            let fact = FileFactV1 {
9308                action: FileActionV1::Changed,
9309                relative_path: Some(relative_path.to_owned()),
9310                evidence: ObservationEvidenceV1::WorkspaceObservation,
9311            };
9312            assert!(fact.validate().is_err(), "accepted {relative_path}");
9313        }
9314    }
9315
9316    #[test]
9317    fn operator_reverse_attribution_is_exact_and_private() {
9318        let subject = HarnessReverseAttributionSubjectV1::FileScope {
9319            workspace: reverse_attribution_workspace(),
9320            relative_path: HarnessRepositoryPathV1::new("src/lib.rs").unwrap(),
9321        };
9322        let request = HarnessOperatorRequestV1::ReverseAttributionGet {
9323            subject: subject.clone(),
9324        };
9325        request.validate().unwrap();
9326        let credential = HarnessOperatorCredential::parse(format!(
9327            "g4aho_{}",
9328            "a".repeat(64),
9329        )).unwrap();
9330        HarnessOperatorEnvelopeV1 {
9331            build_stamp: BUILD_STAMP.to_string(),
9332            credential,
9333            request,
9334        }.validate().unwrap();
9335
9336        let response = HarnessReverseAttributionV1 {
9337            subject,
9338            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9339            links: vec![reverse_attribution_link('2')],
9340        };
9341        response.validate().unwrap();
9342        let encoded = serde_json::to_value(
9343            HarnessOperatorResponseV1::ReverseAttribution(response),
9344        ).unwrap();
9345        assert_eq!(
9346            encoded,
9347            serde_json::json!({
9348                "kind": "reverse-attribution",
9349                "value": {
9350                    "subject": {
9351                        "kind": "file-scope",
9352                        "workspace": {
9353                            "node_id": "node-a",
9354                            "node_incarnation_id": "07070707070707070707070707070707",
9355                            "workspace_id": "workspace-a"
9356                        },
9357                        "relative_path": "src/lib.rs"
9358                    },
9359                    "outcome": "attributed",
9360                    "links": [{
9361                        "task_id": "htask_111111111111111111111111",
9362                        "run_id": "hrun_222222222222222222222222",
9363                        "run_revision": 3,
9364                        "binding": {
9365                            "kind": "workspace",
9366                            "workspace": {
9367                                "node_id": "node-a",
9368                                "node_incarnation_id": "07070707070707070707070707070707",
9369                                "workspace_id": "workspace-a"
9370                            }
9371                        },
9372                        "relation": "workspace-scope"
9373                    }]
9374                }
9375            }),
9376        );
9377        let encoded = encoded.to_string();
9378        for forbidden in [
9379            "produced-by",
9380            "modified-by",
9381            "provider_session",
9382            "provider_profile",
9383            "provider_home",
9384            "credential",
9385            "auth",
9386            "canonical_root",
9387            "display_root",
9388            "source_path",
9389        ] {
9390            assert!(!encoded.contains(forbidden), "leaked {forbidden}");
9391        }
9392
9393        let mut unknown = serde_json::to_value(HarnessOperatorRequestV1::ReverseAttributionGet {
9394            subject: HarnessReverseAttributionSubjectV1::Workspace {
9395                workspace: reverse_attribution_workspace(),
9396            },
9397        }).unwrap();
9398        unknown["subject"]["provider_session_id"] = serde_json::json!("forbidden");
9399        assert!(serde_json::from_value::<HarnessOperatorRequestV1>(unknown).is_err());
9400    }
9401
9402    #[test]
9403    fn reverse_attribution_is_bounded_canonical_and_exactly_correlated() {
9404        let subject = HarnessReverseAttributionSubjectV1::FileScope {
9405            workspace: reverse_attribution_workspace(),
9406            relative_path: HarnessRepositoryPathV1::new("src/lib.rs").unwrap(),
9407        };
9408        let first = reverse_attribution_link('2');
9409        let second = reverse_attribution_link('3');
9410        let exact = HarnessReverseAttributionV1 {
9411            subject: subject.clone(),
9412            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9413            links: vec![first.clone(), second.clone()],
9414        };
9415        exact.validate_for(&subject).unwrap();
9416
9417        let mut noncanonical = exact.clone();
9418        noncanonical.links.reverse();
9419        assert!(matches!(
9420            noncanonical.validate(),
9421            Err(HarnessOperatorApiError::InvalidReverseAttribution),
9422        ));
9423        let duplicate = HarnessReverseAttributionV1 {
9424            links: vec![first.clone(), first],
9425            ..exact.clone()
9426        };
9427        assert!(duplicate.validate().is_err());
9428        let over_limit = HarnessReverseAttributionV1 {
9429            links: (0..=HARNESS_REVERSE_ATTRIBUTION_LINKS_MAX)
9430                .map(|index| HarnessReverseAttributionLinkV1 {
9431                    task_id: HarnessTaskId::new(format!("htask_{index:024x}")).unwrap(),
9432                    ..reverse_attribution_link('4')
9433                })
9434                .collect(),
9435            ..exact.clone()
9436        };
9437        assert!(over_limit.validate().is_err());
9438
9439        let unattributed = HarnessReverseAttributionV1 {
9440            subject: subject.clone(),
9441            outcome: HarnessReverseAttributionOutcomeV1::Unattributed,
9442            links: Vec::new(),
9443        };
9444        unattributed.validate().unwrap();
9445        let attributed_empty = HarnessReverseAttributionV1 {
9446            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9447            ..unattributed.clone()
9448        };
9449        assert!(attributed_empty.validate().is_err());
9450        let unattributed_linked = HarnessReverseAttributionV1 {
9451            outcome: HarnessReverseAttributionOutcomeV1::Unattributed,
9452            links: vec![second],
9453            ..exact.clone()
9454        };
9455        assert!(unattributed_linked.validate().is_err());
9456
9457        let other_subject = HarnessReverseAttributionSubjectV1::FileScope {
9458            workspace: reverse_attribution_workspace(),
9459            relative_path: HarnessRepositoryPathV1::new("src/other.rs").unwrap(),
9460        };
9461        assert!(matches!(
9462            exact.validate_for(&other_subject),
9463            Err(HarnessOperatorApiError::InvalidReverseAttribution),
9464        ));
9465    }
9466
9467    #[test]
9468    fn reverse_attribution_relations_reject_false_file_and_binding_claims() {
9469        let file_subject = HarnessReverseAttributionSubjectV1::FileScope {
9470            workspace: reverse_attribution_workspace(),
9471            relative_path: HarnessRepositoryPathV1::new("src/lib.rs").unwrap(),
9472        };
9473        let mut wrong_relation = reverse_attribution_link('2');
9474        wrong_relation.relation = HarnessReverseAttributionRelationV1::WorkspaceBinding;
9475        assert!(HarnessReverseAttributionV1 {
9476            subject: file_subject,
9477            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9478            links: vec![wrong_relation],
9479        }.validate().is_err());
9480
9481        let managed_subject = HarnessReverseAttributionSubjectV1::ManagedRecord {
9482            workspace: reverse_attribution_workspace(),
9483            record_id: HarnessSelectorV1::new("record-a").unwrap(),
9484        };
9485        let mismatched_record = HarnessReverseAttributionLinkV1 {
9486            task_id: HarnessTaskId::new(format!("htask_{}", "1".repeat(24))).unwrap(),
9487            run_id: HarnessRunId::new(format!("hrun_{}", "2".repeat(24))).unwrap(),
9488            run_revision: HarnessRevision::new(3).unwrap(),
9489            binding: HarnessReverseAttributionBindingV1::ManagedRecord {
9490                workspace: reverse_attribution_workspace(),
9491                record_id: HarnessSelectorV1::new("record-b").unwrap(),
9492                active_instance_id: Some(41),
9493                active_generation: Some(3),
9494            },
9495            relation: HarnessReverseAttributionRelationV1::ManagedRecordBinding,
9496        };
9497        assert!(HarnessReverseAttributionV1 {
9498            subject: managed_subject,
9499            outcome: HarnessReverseAttributionOutcomeV1::Attributed,
9500            links: vec![mismatched_record],
9501        }.validate().is_err());
9502
9503        let invalid_active_pair = HarnessReverseAttributionBindingV1::ManagedRecord {
9504            workspace: reverse_attribution_workspace(),
9505            record_id: HarnessSelectorV1::new("record-a").unwrap(),
9506            active_instance_id: Some(41),
9507            active_generation: None,
9508        };
9509        assert!(invalid_active_pair.validate().is_err());
9510    }
9511
9512    #[test]
9513    fn operator_node_workspace_requests_are_exact_round_trips() {
9514        let node_id = node_workspace_origin().node_id;
9515        let workspace_id = node_workspace_origin().workspace_id;
9516        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
9517        let object_id = HarnessGitObjectIdV1::new("a".repeat(40)).unwrap();
9518        let requests = vec![
9519            HarnessOperatorRequestV1::InspectNodeWorkspace {
9520                node_id: node_id.clone(),
9521                workspace_id: workspace_id.clone(),
9522            },
9523            HarnessOperatorRequestV1::ReadNodeWorkspaceFile {
9524                node_id: node_id.clone(),
9525                workspace_id: workspace_id.clone(),
9526                path: path.clone(),
9527            },
9528            HarnessOperatorRequestV1::ReadNodeGitHistory {
9529                node_id: node_id.clone(),
9530                workspace_id: workspace_id.clone(),
9531                path: Some(path.clone()),
9532                before: Some(object_id.clone()),
9533                limit: HARNESS_GIT_HISTORY_LIMIT_MAX,
9534            },
9535            HarnessOperatorRequestV1::ReadNodeGitDiff {
9536                node_id: node_id.clone(),
9537                workspace_id: workspace_id.clone(),
9538                mode: HarnessGitDiffModeV1::Commit { revision: object_id },
9539                path: Some(path),
9540            },
9541        ];
9542        let credential = HarnessOperatorCredential::parse(format!(
9543            "g4aho_{}",
9544            "a".repeat(64),
9545        )).unwrap();
9546        for request in requests {
9547            request.validate().expect("valid node-workspace request");
9548            let encoded = serde_json::to_string(&request).unwrap();
9549            for forbidden in ["run_id", "endpoint", "root", "worktree", "environment"] {
9550                assert!(!encoded.contains(forbidden), "request exposed {forbidden}");
9551            }
9552            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
9553            assert_eq!(decoded, request);
9554            HarnessOperatorEnvelopeV1 {
9555                build_stamp: BUILD_STAMP.to_string(),
9556                credential: credential.clone(),
9557                request,
9558            }.validate().unwrap();
9559        }
9560
9561        assert!(HarnessOperatorRequestV1::InspectNodeWorkspace {
9562            node_id: String::new(),
9563            workspace_id,
9564        }.validate().is_err());
9565        assert!(HarnessOperatorRequestV1::InspectNodeWorkspace {
9566            node_id,
9567            workspace_id: String::new(),
9568        }.validate().is_err());
9569    }
9570
9571    #[test]
9572    fn operator_v9_node_workspace_replies_round_trip_without_run_or_diagnostic_fields() {
9573        let origin = node_workspace_origin();
9574        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
9575        let commit = git_commit('a');
9576        let responses = vec![
9577            HarnessOperatorResponseV1::NodeWorkspaceInspected(
9578                HarnessNodeWorkspaceInspectionV1 {
9579                    origin: origin.clone(),
9580                    entries: vec![HarnessWorkspaceTreeEntryV1 {
9581                        relative_path: path.clone(),
9582                        kind: HarnessWorkspaceEntryKindV1::File,
9583                    }],
9584                    tree_truncated: false,
9585                    git: HarnessGitSummaryV1 {
9586                        is_repository: true,
9587                        branch: Some("main".to_owned()),
9588                        status: vec![HarnessGitStatusEntryV1 {
9589                            index_status: HarnessGitStatusCodeV1::Unmodified,
9590                            worktree_status: HarnessGitStatusCodeV1::Modified,
9591                            path: path.clone(),
9592                            previous_path: None,
9593                        }],
9594                        recent_commits: vec![HarnessGitCommitSummaryV1 {
9595                            id: commit.id.clone(),
9596                            summary: commit.subject.clone(),
9597                        }],
9598                        truncated: false,
9599                    },
9600                    truncation: None,
9601                },
9602            ),
9603            HarnessOperatorResponseV1::NodeWorkspaceFileRead(HarnessNodeWorkspaceFileV1 {
9604                origin: origin.clone(),
9605                path: path.clone(),
9606                content: HarnessWorkspaceFileContentV1::Utf8 {
9607                    text: "fn main() {}\n".to_owned(),
9608                    byte_len: 13,
9609                },
9610                revision: Some(HarnessWorkspaceFileRevisionV1::new("b".repeat(64)).unwrap()),
9611            }),
9612            HarnessOperatorResponseV1::NodeGitHistoryRead(HarnessNodeGitHistoryPageV1 {
9613                origin: origin.clone(),
9614                path: Some(path.clone()),
9615                commits: vec![commit],
9616                next_before: None,
9617                truncated: false,
9618            }),
9619            HarnessOperatorResponseV1::NodeGitDiffRead(HarnessNodeGitDiffV1 {
9620                origin,
9621                mode: HarnessGitDiffModeV1::Working,
9622                path: Some(path),
9623                text: "diff --git a/src/lib.rs b/src/lib.rs\n".to_owned(),
9624                truncated: false,
9625            }),
9626        ];
9627        for response in responses {
9628            response.validate().expect("valid V9 node-workspace response");
9629            let encoded = serde_json::to_string(&response).unwrap();
9630            assert_json_has_no_forbidden_keys(&encoded, &[
9631                "run_id",
9632                "run_revision",
9633                "root",
9634                "worktree",
9635                "endpoint",
9636                "diagnostic",
9637                "author_email",
9638                "committer_email",
9639                "environment",
9640            ]);
9641            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
9642            assert_eq!(decoded, response);
9643        }
9644
9645        let mismatched_workspace = HarnessNodeWorkspaceOriginV1 {
9646            workspace_id: "other-workspace".to_owned(),
9647            ..node_workspace_origin()
9648        };
9649        assert!(mismatched_workspace.validate_for("node-a", "workspace-a").is_err());
9650        mismatched_workspace.validate_for("node-a", "other-workspace").unwrap();
9651    }
9652
9653    #[test]
9654    fn operator_node_workspace_write_verbs_are_exact_round_trips() {
9655        let node_id = node_workspace_origin().node_id;
9656        let workspace_id = node_workspace_origin().workspace_id;
9657        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
9658        let revision = HarnessWorkspaceFileRevisionV1::new("b".repeat(64)).unwrap();
9659        let requests = vec![
9660            HarnessOperatorRequestV1::WriteNodeWorkspaceFile {
9661                node_id: node_id.clone(),
9662                workspace_id: workspace_id.clone(),
9663                path: path.clone(),
9664                content: "fn main() {}\n".to_owned(),
9665                expected_revision: revision.clone(),
9666            },
9667            HarnessOperatorRequestV1::CreateNodeWorkspaceFile {
9668                node_id: node_id.clone(),
9669                workspace_id: workspace_id.clone(),
9670                path: path.clone(),
9671            },
9672            HarnessOperatorRequestV1::CreateNodeWorkspaceDirectory {
9673                node_id: node_id.clone(),
9674                workspace_id: workspace_id.clone(),
9675                path: path.clone(),
9676            },
9677        ];
9678        let credential = HarnessOperatorCredential::parse(format!(
9679            "g4aho_{}",
9680            "a".repeat(64),
9681        )).unwrap();
9682        for request in requests {
9683            request.validate().expect("valid node-workspace write request");
9684            let encoded = serde_json::to_string(&request).unwrap();
9685            for forbidden in ["run_id", "endpoint", "root", "worktree", "environment"] {
9686                assert!(!encoded.contains(forbidden), "request exposed {forbidden}");
9687            }
9688            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
9689            assert_eq!(decoded, request);
9690            HarnessOperatorEnvelopeV1 {
9691                build_stamp: BUILD_STAMP.to_string(),
9692                credential: credential.clone(),
9693                request,
9694            }.validate().unwrap();
9695        }
9696
9697        let origin = node_workspace_origin();
9698        let responses = vec![
9699            HarnessOperatorResponseV1::NodeWorkspaceFileWritten(HarnessNodeWorkspaceFileV1 {
9700                origin: origin.clone(),
9701                path: path.clone(),
9702                content: HarnessWorkspaceFileContentV1::Utf8 {
9703                    text: "fn main() {}\n".to_owned(),
9704                    byte_len: 13,
9705                },
9706                revision: Some(revision.clone()),
9707            }),
9708            HarnessOperatorResponseV1::NodeWorkspaceFileCreated(HarnessNodeWorkspaceFileV1 {
9709                origin: origin.clone(),
9710                path: path.clone(),
9711                content: HarnessWorkspaceFileContentV1::Utf8 {
9712                    text: String::new(),
9713                    byte_len: 0,
9714                },
9715                revision: Some(revision.clone()),
9716            }),
9717            HarnessOperatorResponseV1::NodeWorkspaceDirectoryCreated(
9718                HarnessNodeWorkspaceDirectoryV1 {
9719                    origin: origin.clone(),
9720                    entry: HarnessWorkspaceTreeEntryV1 {
9721                        relative_path: path.clone(),
9722                        kind: HarnessWorkspaceEntryKindV1::Directory,
9723                    },
9724                },
9725            ),
9726        ];
9727        for response in responses {
9728            response.validate().expect("valid V11 node-workspace write response");
9729            let encoded = serde_json::to_string(&response).unwrap();
9730            assert_json_has_no_forbidden_keys(&encoded, &[
9731                "run_id",
9732                "run_revision",
9733                "root",
9734                "worktree",
9735                "endpoint",
9736                "diagnostic",
9737                "environment",
9738            ]);
9739            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
9740            assert_eq!(decoded, response);
9741        }
9742
9743        let wrong_kind_directory = HarnessNodeWorkspaceDirectoryV1 {
9744            origin,
9745            entry: HarnessWorkspaceTreeEntryV1 {
9746                relative_path: path,
9747                kind: HarnessWorkspaceEntryKindV1::File,
9748            },
9749        };
9750        assert!(wrong_kind_directory.validate().is_err());
9751    }
9752
9753    #[test]
9754    fn node_workspace_write_v11_requests_reject_malformed_fields() {
9755        let node_id = node_workspace_origin().node_id;
9756        let workspace_id = node_workspace_origin().workspace_id;
9757        let path = HarnessRepositoryPathV1::new("src/lib.rs").unwrap();
9758        let revision = HarnessWorkspaceFileRevisionV1::new("b".repeat(64)).unwrap();
9759
9760        let oversized_content = HarnessOperatorRequestV1::WriteNodeWorkspaceFile {
9761            node_id: node_id.clone(),
9762            workspace_id: workspace_id.clone(),
9763            path: path.clone(),
9764            content: "x".repeat(HARNESS_WORKSPACE_FILE_MAX_BYTES + 1),
9765            expected_revision: revision.clone(),
9766        };
9767        assert!(matches!(
9768            oversized_content.validate(),
9769            Err(HarnessOperatorApiError::InvalidWorkspaceFile),
9770        ));
9771        let bounded_content = HarnessOperatorRequestV1::WriteNodeWorkspaceFile {
9772            node_id: node_id.clone(),
9773            workspace_id: workspace_id.clone(),
9774            path: path.clone(),
9775            content: "x".repeat(HARNESS_WORKSPACE_FILE_MAX_BYTES),
9776            expected_revision: revision.clone(),
9777        };
9778        bounded_content.validate().unwrap();
9779
9780        assert!(HarnessWorkspaceFileRevisionV1::new("not-a-sha256-digest").is_err());
9781        assert!(HarnessWorkspaceFileRevisionV1::new("A".repeat(64)).is_err());
9782
9783        let empty_node_write = HarnessOperatorRequestV1::WriteNodeWorkspaceFile {
9784            node_id: String::new(),
9785            workspace_id: workspace_id.clone(),
9786            path: path.clone(),
9787            content: "ok".to_owned(),
9788            expected_revision: revision,
9789        };
9790        assert!(empty_node_write.validate().is_err());
9791
9792        let empty_workspace_create_file = HarnessOperatorRequestV1::CreateNodeWorkspaceFile {
9793            node_id: node_id.clone(),
9794            workspace_id: String::new(),
9795            path: path.clone(),
9796        };
9797        assert!(empty_workspace_create_file.validate().is_err());
9798
9799        let empty_node_create_directory = HarnessOperatorRequestV1::CreateNodeWorkspaceDirectory {
9800            node_id: String::new(),
9801            workspace_id,
9802            path,
9803        };
9804        assert!(empty_node_create_directory.validate().is_err());
9805    }
9806
9807    #[test]
9808    fn operator_context_source_observation_is_exact_and_private() {
9809        let run_id = HarnessRunId::new(format!("hrun_{}", "8".repeat(24))).unwrap();
9810        let request = HarnessOperatorRequestV1::ObserveRunContextSource {
9811            run_id: run_id.clone(),
9812        };
9813        request.validate().unwrap();
9814        let request_json = serde_json::to_value(&request).unwrap();
9815        assert_eq!(
9816            request_json,
9817            serde_json::json!({
9818                "kind": "observe-run-context-source",
9819                "run_id": run_id,
9820            }),
9821        );
9822        for forbidden in ["authority", "operation_id", "idempotency_ref", "task_id"] {
9823            assert!(!request_json.to_string().contains(forbidden), "leaked {forbidden}");
9824        }
9825        let credential = HarnessOperatorCredential::parse(format!(
9826            "g4aho_{}",
9827            "a".repeat(64),
9828        )).unwrap();
9829        HarnessOperatorEnvelopeV1 {
9830            build_stamp: BUILD_STAMP.to_string(),
9831            credential,
9832            request,
9833        }.validate().unwrap();
9834
9835        let response = HarnessRunContextSourceObservationV1 {
9836            run_id: HarnessRunId::new(format!("hrun_{}", "8".repeat(24))).unwrap(),
9837            run_revision: HarnessRevision::new(9).unwrap(),
9838            feature_state: FeatureObservationStateV1::Observed,
9839            message_count: 17,
9840            message_count_exact: true,
9841            completed_turn_count: Some(6),
9842            total_tokens: Some(4_096),
9843            observed_at_unix_ms: Some(1_000),
9844        };
9845        response.validate().unwrap();
9846        let response_json = serde_json::to_value(
9847            HarnessOperatorResponseV1::RunContextSourceObserved(response),
9848        ).unwrap();
9849        assert_eq!(
9850            response_json,
9851            serde_json::json!({
9852                "kind": "run-context-source-observed",
9853                "value": {
9854                    "run_id": format!("hrun_{}", "8".repeat(24)),
9855                    "run_revision": 9,
9856                    "feature_state": "observed",
9857                    "message_count": 17,
9858                    "message_count_exact": true,
9859                    "completed_turn_count": 6,
9860                    "total_tokens": 4096,
9861                    "observed_at_unix_ms": 1000,
9862                }
9863            }),
9864        );
9865        let response_json = response_json.to_string();
9866        for forbidden in [
9867            "transcript",
9868            "message_text",
9869            "provider_session",
9870            "provider_profile",
9871            "provider_home",
9872            "credential",
9873            "auth",
9874            "path",
9875            "model",
9876        ] {
9877            assert!(!response_json.contains(forbidden), "leaked {forbidden}");
9878        }
9879
9880        let mut unknown = request_json;
9881        unknown["provider_session_id"] = serde_json::json!("forbidden");
9882        assert!(serde_json::from_value::<HarnessOperatorRequestV1>(unknown).is_err());
9883    }
9884
9885    #[test]
9886    fn context_source_observation_rejects_inexact_or_mismatched_eligibility_claims() {
9887        let run_id = HarnessRunId::new(format!("hrun_{}", "8".repeat(24))).unwrap();
9888        let observed = HarnessRunContextSourceObservationV1 {
9889            run_id: run_id.clone(),
9890            run_revision: HarnessRevision::new(9).unwrap(),
9891            feature_state: FeatureObservationStateV1::Observed,
9892            message_count: 17,
9893            message_count_exact: true,
9894            completed_turn_count: Some(6),
9895            total_tokens: Some(4_096),
9896            observed_at_unix_ms: Some(1_000),
9897        };
9898        observed.validate_for(&run_id).unwrap();
9899
9900        let mut inexact = observed.clone();
9901        inexact.message_count_exact = false;
9902        assert!(inexact.validate().is_err());
9903        let mut empty = observed.clone();
9904        empty.message_count = 0;
9905        assert!(empty.validate().is_err());
9906        let mut impossible_turns = observed.clone();
9907        impossible_turns.completed_turn_count = Some(18);
9908        assert!(impossible_turns.validate().is_err());
9909        let unobserved = HarnessRunContextSourceObservationV1 {
9910            feature_state: FeatureObservationStateV1::SupportedNotObserved,
9911            message_count: 0,
9912            message_count_exact: false,
9913            completed_turn_count: None,
9914            total_tokens: None,
9915            observed_at_unix_ms: None,
9916            ..observed.clone()
9917        };
9918        unobserved.validate().unwrap();
9919        let mut leaked_count = unobserved;
9920        leaked_count.total_tokens = Some(1);
9921        assert!(leaked_count.validate().is_err());
9922
9923        let other_run_id = HarnessRunId::new(format!("hrun_{}", "9".repeat(24))).unwrap();
9924        assert!(matches!(
9925            observed.validate_for(&other_run_id),
9926            Err(HarnessOperatorApiError::InvalidRunContextSourceObservation),
9927        ));
9928    }
9929
9930    fn session_address(instance_id: u64, generation: u64) -> HarnessRuntimeSessionAddressV1 {
9931        HarnessRuntimeSessionAddressV1 {
9932            node_id: "node-a".to_owned(),
9933            incarnation_id: "07".repeat(16),
9934            workspace_id: "workspace-a".to_owned(),
9935            instance_id,
9936            generation,
9937        }
9938    }
9939
9940    fn sample_agent_stream_chunk(source_sequence: u64) -> HarnessAgentStreamChunkV1 {
9941        HarnessAgentStreamChunkV1 {
9942            source_sequence,
9943            kind: HarnessAgentStreamChunkKindV1::Text {
9944                text: format!("chunk-{source_sequence}"),
9945                is_delta: true,
9946            },
9947        }
9948    }
9949
9950    fn sample_terminal_frame(sequence: u64) -> HarnessRuntimeTerminalFrameV1 {
9951        HarnessRuntimeTerminalFrameV1 {
9952            sequence,
9953            size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 },
9954            cursor_row: 0,
9955            cursor_column: 0,
9956            formatted: format!("frame-{sequence}").into_bytes(),
9957            scrollback_formatted: Vec::new(),
9958            alternate_screen: false,
9959            mouse_protocol_enabled: false,
9960            mouse_protocol_encoding: HarnessRuntimeMouseProtocolEncodingV1::Default,
9961            produced_at_unix_ms: 1_000,
9962            screen_state: None,
9963            bracketed_paste: None,
9964        }
9965    }
9966
9967    fn sample_runtime_session(instance_id: u64, generation: u64) -> HarnessRuntimeSessionV1 {
9968        HarnessRuntimeSessionV1 {
9969            instance_id,
9970            generation,
9971            provider: "codex".to_owned(),
9972            transport: HarnessRuntimeTransportV1::Pty,
9973            status: HarnessRuntimeSessionStatusV1::Running,
9974            process_id: Some(1234),
9975            terminal_size: Some(HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 }),
9976            operation_pending: false,
9977            input_pending: false,
9978            screen_state: None,
9979        }
9980    }
9981
9982    /// A fully-populated `OperatorGateStateV1` -- one accepted option, one
9983    /// declined, matching the shape `gate4agent-shell-native`'s numbered-list
9984    /// parser actually produces (see that crate's own tests for the parser
9985    /// itself; this crate only owns the wire mirror and its bounds).
9986    fn sample_operator_gate_state() -> OperatorGateStateV1 {
9987        OperatorGateStateV1 {
9988            kind: OperatorGateKindV1::HookTrust,
9989            subject: OperatorGateSubjectV1::Hooks { count: Some(6) },
9990            input: OperatorGateInputV1::NumberedList,
9991            options: vec![
9992                OperatorGateOptionV1 {
9993                    text: "Trust all and continue".to_owned(),
9994                    semantics: OperatorGateOptionSemanticsV1::Accept,
9995                    selected: false,
9996                },
9997                OperatorGateOptionV1 {
9998                    text: "Continue without trusting".to_owned(),
9999                    semantics: OperatorGateOptionSemanticsV1::Decline,
10000                    selected: true,
10001                },
10002            ],
10003        }
10004    }
10005
10006    /// Every `PtyScreenStateV1` variant, for the round-trip and validation
10007    /// sweeps below -- kept as one list so a variant added to the enum
10008    /// without a matching addition here is caught by a stale sweep rather
10009    /// than silently under-covered.
10010    fn every_screen_state_variant() -> Vec<PtyScreenStateV1> {
10011        vec![
10012            PtyScreenStateV1::Unknown,
10013            PtyScreenStateV1::NotAgent { observed_process: "installer.exe".to_owned() },
10014            PtyScreenStateV1::OperatorGate { gate: sample_operator_gate_state() },
10015            PtyScreenStateV1::Failing { reason: "crash-loop".to_owned() },
10016            PtyScreenStateV1::Ready,
10017        ]
10018    }
10019
10020    /// The literal proof the `deny_unknown_fields` hazard is closed: a
10021    /// pre-V13 reply (`screen_state: None`) must omit the KEY entirely, not
10022    /// serialize it as `null` -- an older client's struct has no such field
10023    /// at all, so a `null` value would still trip `deny_unknown_fields`.
10024    #[test]
10025    fn a_terminal_frame_with_no_screen_state_serializes_without_the_key_at_all() {
10026        let frame = sample_terminal_frame(1);
10027        assert!(frame.screen_state.is_none());
10028        let value = serde_json::to_value(&frame).unwrap();
10029        assert!(
10030            value.as_object().unwrap().get("screen_state").is_none(),
10031            "expected no screen_state key in {value}",
10032        );
10033    }
10034
10035    /// Sibling of the above for `HarnessRuntimeSessionV1` -- the session
10036    /// inventory's own reply carries the identical hazard and the identical
10037    /// fix.
10038    #[test]
10039    fn a_runtime_session_with_no_screen_state_serializes_without_the_key_at_all() {
10040        let session = sample_runtime_session(1, 1);
10041        assert!(session.screen_state.is_none());
10042        let value = serde_json::to_value(&session).unwrap();
10043        assert!(
10044            value.as_object().unwrap().get("screen_state").is_none(),
10045            "expected no screen_state key in {value}",
10046        );
10047    }
10048
10049    /// `Some(..)` round-trips byte-exact for every variant, on both wire
10050    /// types that carry it.
10051    #[test]
10052    fn some_screen_state_round_trips_exactly_for_every_variant_on_both_carriers() {
10053        for variant in every_screen_state_variant() {
10054            let mut frame = sample_terminal_frame(1);
10055            frame.screen_state = Some(variant.clone());
10056            let decoded_frame: HarnessRuntimeTerminalFrameV1 = serde_json::from_slice(
10057                &serde_json::to_vec(&frame).unwrap(),
10058            ).unwrap();
10059            assert_eq!(decoded_frame, frame);
10060
10061            let mut session = sample_runtime_session(1, 1);
10062            session.screen_state = Some(variant.clone());
10063            let decoded_session: HarnessRuntimeSessionV1 = serde_json::from_slice(
10064                &serde_json::to_vec(&session).unwrap(),
10065            ).unwrap();
10066            assert_eq!(decoded_session, session);
10067        }
10068    }
10069
10070    /// `validate()` rejects an oversized carried string inside a present
10071    /// `screen_state`, on both carriers -- the bound is enforced regardless
10072    /// of which wire type happens to hold the value.
10073    #[test]
10074    fn validate_rejects_an_oversized_string_inside_a_present_screen_state() {
10075        let oversized_process = "x".repeat(HARNESS_SCREEN_STATE_PROCESS_MAX_BYTES + 1);
10076        let oversized_gate = "x".repeat(HARNESS_SCREEN_STATE_GATE_MAX_BYTES + 1);
10077        let cases = [
10078            PtyScreenStateV1::NotAgent { observed_process: oversized_process },
10079            PtyScreenStateV1::Failing { reason: oversized_gate },
10080        ];
10081        for case in cases {
10082            let mut frame = sample_terminal_frame(1);
10083            frame.screen_state = Some(case.clone());
10084            assert!(
10085                matches!(frame.validate(), Err(HarnessOperatorApiError::InvalidRuntimeInventory)),
10086                "expected frame validate() to reject {case:?}",
10087            );
10088
10089            let mut session = sample_runtime_session(1, 1);
10090            session.screen_state = Some(case.clone());
10091            assert!(
10092                matches!(session.validate(), Err(HarnessOperatorApiError::InvalidRuntimeInventory)),
10093                "expected session validate() to reject {case:?}",
10094            );
10095        }
10096    }
10097
10098    /// Sibling of the sweep above for `OperatorGateStateV1`'s own nested
10099    /// bounds -- an oversized `Directory` path and an oversized option
10100    /// label are each, on their own, enough to fail `validate()`, on both
10101    /// carriers.
10102    #[test]
10103    fn validate_rejects_an_oversized_field_nested_inside_an_operator_gate() {
10104        let oversized_path = "x".repeat(HARNESS_GATE_PATH_MAX_BYTES + 1);
10105        let oversized_option_text = "x".repeat(HARNESS_GATE_OPTION_TEXT_MAX_BYTES + 1);
10106        let cases = [
10107            PtyScreenStateV1::OperatorGate {
10108                gate: OperatorGateStateV1 {
10109                    subject: OperatorGateSubjectV1::Directory { path: Some(oversized_path) },
10110                    ..sample_operator_gate_state()
10111                },
10112            },
10113            PtyScreenStateV1::OperatorGate {
10114                gate: OperatorGateStateV1 {
10115                    options: vec![OperatorGateOptionV1 {
10116                        text: oversized_option_text,
10117                        semantics: OperatorGateOptionSemanticsV1::Accept,
10118                        selected: false,
10119                    }],
10120                    ..sample_operator_gate_state()
10121                },
10122            },
10123        ];
10124        for case in cases {
10125            let mut frame = sample_terminal_frame(1);
10126            frame.screen_state = Some(case.clone());
10127            assert!(
10128                matches!(frame.validate(), Err(HarnessOperatorApiError::InvalidRuntimeInventory)),
10129                "expected frame validate() to reject {case:?}",
10130            );
10131
10132            let mut session = sample_runtime_session(1, 1);
10133            session.screen_state = Some(case.clone());
10134            assert!(
10135                matches!(session.validate(), Err(HarnessOperatorApiError::InvalidRuntimeInventory)),
10136                "expected session validate() to reject {case:?}",
10137            );
10138        }
10139    }
10140
10141    #[test]
10142    fn operator_session_verbs_are_exact_round_trips() {
10143        let session = session_address(41, 3);
10144        let requests = vec![
10145            HarnessOperatorRequestV1::SpawnSession {
10146                node_id: "node-a".to_owned(),
10147                workspace_id: "workspace-a".to_owned(),
10148                provider: "claude".to_owned(),
10149                provider_profile: "claude-default".to_owned(),
10150                mode: HarnessExecutionModeV1::Pty,
10151                terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 40, columns: 120 },
10152                approval_level: None,
10153            },
10154            HarnessOperatorRequestV1::WriteSessionInput {
10155                session: session.clone(),
10156                text: "echo hi\n".to_owned(),
10157            },
10158            HarnessOperatorRequestV1::ResizeSession {
10159                session: session.clone(),
10160                terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 50, columns: 160 },
10161            },
10162            HarnessOperatorRequestV1::StopSession {
10163                session: session.clone(),
10164                force: true,
10165            },
10166        ];
10167        let credential = HarnessOperatorCredential::parse(format!(
10168            "g4aho_{}",
10169            "a".repeat(64),
10170        )).unwrap();
10171        for request in requests {
10172            request.validate().expect("valid session verb request");
10173            let encoded = serde_json::to_string(&request).unwrap();
10174            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10175            assert_eq!(decoded, request);
10176            HarnessOperatorEnvelopeV1 {
10177                build_stamp: BUILD_STAMP.to_string(),
10178                credential: credential.clone(),
10179                request,
10180            }.validate().unwrap();
10181        }
10182
10183        let responses = vec![
10184            HarnessOperatorResponseV1::SessionSpawned(session.clone()),
10185            HarnessOperatorResponseV1::SessionInputWritten,
10186            HarnessOperatorResponseV1::SessionResized,
10187            HarnessOperatorResponseV1::SessionStopped,
10188        ];
10189        for response in responses {
10190            response.validate().expect("valid V10 session verb response");
10191            let encoded = serde_json::to_string(&response).unwrap();
10192            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10193            assert_eq!(decoded, response);
10194        }
10195    }
10196
10197    /// `PromptSession`'s own round trip, kept separate from the V10 session-
10198    /// verb test above: it landed on the wire under a later version and
10199    /// carries its own PTY-refusal doctrine (see its doc comment), not just
10200    /// another `WriteSessionInput`-shaped verb.
10201    #[test]
10202    fn operator_prompt_session_is_exact_round_trip() {
10203        let session = session_address(41, 3);
10204        let request = HarnessOperatorRequestV1::PromptSession {
10205            session: session.clone(),
10206            text: "please continue".to_owned(),
10207        };
10208        request.validate().expect("valid prompt-session request");
10209        let encoded = serde_json::to_string(&request).unwrap();
10210        let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10211        assert_eq!(decoded, request);
10212        let credential = HarnessOperatorCredential::parse(format!(
10213            "g4aho_{}",
10214            "a".repeat(64),
10215        )).unwrap();
10216        HarnessOperatorEnvelopeV1 {
10217            build_stamp: BUILD_STAMP.to_string(),
10218            credential,
10219            request,
10220        }.validate().unwrap();
10221
10222        let response = HarnessOperatorResponseV1::SessionPrompted;
10223        response.validate().expect("valid prompt-session response");
10224        let encoded = serde_json::to_string(&response).unwrap();
10225        let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10226        assert_eq!(decoded, response);
10227    }
10228
10229    #[test]
10230    fn session_spawn_and_control_requests_reject_malformed_fields() {
10231        let session = session_address(41, 3);
10232        let valid_spawn = HarnessOperatorRequestV1::SpawnSession {
10233            node_id: "node-a".to_owned(),
10234            workspace_id: "workspace-a".to_owned(),
10235            provider: "claude".to_owned(),
10236            provider_profile: "claude-default".to_owned(),
10237            mode: HarnessExecutionModeV1::Pty,
10238            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 40, columns: 120 },
10239            approval_level: None,
10240        };
10241        valid_spawn.validate().unwrap();
10242
10243        let mut empty_node = valid_spawn.clone();
10244        if let HarnessOperatorRequestV1::SpawnSession { node_id, .. } = &mut empty_node {
10245            *node_id = String::new();
10246        }
10247        assert!(matches!(
10248            empty_node.validate(),
10249            Err(HarnessOperatorApiError::InvalidWorkspaceOrigin),
10250        ));
10251
10252        let mut bad_provider = valid_spawn.clone();
10253        if let HarnessOperatorRequestV1::SpawnSession { provider, .. } = &mut bad_provider {
10254            *provider = "not a provider id!".to_owned();
10255        }
10256        assert!(matches!(
10257            bad_provider.validate(),
10258            Err(HarnessOperatorApiError::InvalidSessionSpawn),
10259        ));
10260
10261        let mut zero_rows = valid_spawn.clone();
10262        if let HarnessOperatorRequestV1::SpawnSession { terminal_size, .. } = &mut zero_rows {
10263            terminal_size.rows = 0;
10264        }
10265        assert!(matches!(
10266            zero_rows.validate(),
10267            Err(HarnessOperatorApiError::InvalidSessionSpawn),
10268        ));
10269
10270        let oversized_input = HarnessOperatorRequestV1::WriteSessionInput {
10271            session: session.clone(),
10272            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES + 1),
10273        };
10274        assert!(matches!(
10275            oversized_input.validate(),
10276            Err(HarnessOperatorApiError::InvalidSessionControl),
10277        ));
10278        let bounded_input = HarnessOperatorRequestV1::WriteSessionInput {
10279            session: session.clone(),
10280            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES),
10281        };
10282        bounded_input.validate().unwrap();
10283
10284        let oversized_prompt = HarnessOperatorRequestV1::PromptSession {
10285            session: session.clone(),
10286            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES + 1),
10287        };
10288        assert!(matches!(
10289            oversized_prompt.validate(),
10290            Err(HarnessOperatorApiError::InvalidSessionControl),
10291        ));
10292        let bounded_prompt = HarnessOperatorRequestV1::PromptSession {
10293            session: session.clone(),
10294            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES),
10295        };
10296        bounded_prompt.validate().unwrap();
10297
10298        let zero_resize = HarnessOperatorRequestV1::ResizeSession {
10299            session: session.clone(),
10300            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 0 },
10301        };
10302        assert!(matches!(
10303            zero_resize.validate(),
10304            Err(HarnessOperatorApiError::InvalidSessionControl),
10305        ));
10306
10307        let mut malformed_session = session.clone();
10308        malformed_session.instance_id = 0;
10309        let bad_stop = HarnessOperatorRequestV1::StopSession {
10310            session: malformed_session,
10311            force: false,
10312        };
10313        assert!(bad_stop.validate().is_err());
10314    }
10315
10316    #[test]
10317    fn operator_session_control_verbs_are_exact_round_trips() {
10318        let session = session_address(41, 3);
10319        let requests = vec![
10320            HarnessOperatorRequestV1::ControlSession {
10321                session: session.clone(),
10322                control: HarnessTerminalControlV1::Enter,
10323            },
10324            HarnessOperatorRequestV1::WriteSessionBytes {
10325                session: session.clone(),
10326                bytes: vec![0x1b, b'[', b'A'],
10327            },
10328            HarnessOperatorRequestV1::PasteSession {
10329                session: session.clone(),
10330                text: "pasted text\n".to_owned(),
10331            },
10332            HarnessOperatorRequestV1::RemoveSession {
10333                session: session.clone(),
10334            },
10335            HarnessOperatorRequestV1::ResumeSession {
10336                session: session.clone(),
10337                terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 },
10338            },
10339        ];
10340        let credential = HarnessOperatorCredential::parse(format!(
10341            "g4aho_{}",
10342            "a".repeat(64),
10343        )).unwrap();
10344        for request in requests {
10345            request.validate().expect("valid session control verb request");
10346            let encoded = serde_json::to_string(&request).unwrap();
10347            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10348            assert_eq!(decoded, request);
10349            HarnessOperatorEnvelopeV1 {
10350                build_stamp: BUILD_STAMP.to_string(),
10351                credential: credential.clone(),
10352                request,
10353            }.validate().unwrap();
10354        }
10355
10356        let responses = vec![
10357            HarnessOperatorResponseV1::SessionControlled,
10358            HarnessOperatorResponseV1::SessionBytesWritten,
10359            HarnessOperatorResponseV1::SessionPasted,
10360            HarnessOperatorResponseV1::SessionRemoved,
10361            HarnessOperatorResponseV1::SessionResumed,
10362        ];
10363        for response in responses {
10364            response.validate().expect("valid V11 session control verb response");
10365            let encoded = serde_json::to_string(&response).unwrap();
10366            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10367            assert_eq!(decoded, response);
10368        }
10369    }
10370
10371    #[test]
10372    fn session_control_v11_requests_reject_malformed_fields() {
10373        let session = session_address(41, 3);
10374
10375        let oversized_bytes = HarnessOperatorRequestV1::WriteSessionBytes {
10376            session: session.clone(),
10377            bytes: vec![0u8; HARNESS_SESSION_BYTES_MAX_BYTES + 1],
10378        };
10379        assert!(matches!(
10380            oversized_bytes.validate(),
10381            Err(HarnessOperatorApiError::InvalidSessionControl),
10382        ));
10383        let empty_bytes = HarnessOperatorRequestV1::WriteSessionBytes {
10384            session: session.clone(),
10385            bytes: Vec::new(),
10386        };
10387        assert!(matches!(
10388            empty_bytes.validate(),
10389            Err(HarnessOperatorApiError::InvalidSessionControl),
10390        ));
10391        let bounded_bytes = HarnessOperatorRequestV1::WriteSessionBytes {
10392            session: session.clone(),
10393            bytes: vec![0u8; HARNESS_SESSION_BYTES_MAX_BYTES],
10394        };
10395        bounded_bytes.validate().unwrap();
10396
10397        let oversized_paste = HarnessOperatorRequestV1::PasteSession {
10398            session: session.clone(),
10399            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES + 1),
10400        };
10401        assert!(matches!(
10402            oversized_paste.validate(),
10403            Err(HarnessOperatorApiError::InvalidSessionControl),
10404        ));
10405        let bounded_paste = HarnessOperatorRequestV1::PasteSession {
10406            session: session.clone(),
10407            text: "x".repeat(HARNESS_SESSION_INPUT_MAX_BYTES),
10408        };
10409        bounded_paste.validate().unwrap();
10410
10411        let zero_resume_size = HarnessOperatorRequestV1::ResumeSession {
10412            session: session.clone(),
10413            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 0, columns: 80 },
10414        };
10415        assert!(matches!(
10416            zero_resume_size.validate(),
10417            Err(HarnessOperatorApiError::InvalidSessionControl),
10418        ));
10419
10420        let mut malformed_session = session.clone();
10421        malformed_session.instance_id = 0;
10422        let bad_remove = HarnessOperatorRequestV1::RemoveSession {
10423            session: malformed_session.clone(),
10424        };
10425        assert!(bad_remove.validate().is_err());
10426        let bad_control = HarnessOperatorRequestV1::ControlSession {
10427            session: malformed_session,
10428            control: HarnessTerminalControlV1::Interrupt,
10429        };
10430        assert!(bad_control.validate().is_err());
10431    }
10432
10433    fn sample_native_session_selection() -> HarnessNativeSessionSelectionV1 {
10434        HarnessNativeSessionSelectionV1 {
10435            route: HarnessNativeSessionRouteV1 {
10436                node_id: "node-a".to_owned(),
10437                incarnation_id: "1".repeat(32),
10438                scope: HarnessNativeSessionCatalogScopeV1::Workspace,
10439                workspace_id: Some("workspace-a".to_owned()),
10440                provider: "codex".to_owned(),
10441            },
10442            catalog_revision: 7,
10443            recent_cutoff_unix_ms: 9,
10444            selection_id: "selection-a".to_owned(),
10445        }
10446    }
10447
10448    fn sample_managed_session(record_id: &str) -> HarnessRuntimeManagedSessionV1 {
10449        HarnessRuntimeManagedSessionV1 {
10450            record_id: record_id.to_owned(),
10451            display_name: "Session display name".to_owned(),
10452            display_name_truncated: false,
10453            provider: "codex".to_owned(),
10454            mode: HarnessRuntimeManagedModeV1::Pty,
10455            state: HarnessRuntimeManagedStateV1::Dormant,
10456            workspace_id: "workspace-a".to_owned(),
10457            active_binding: None,
10458            provider_identity_present: true,
10459            updated_at_unix_ms: 10,
10460            blocked_count: 0,
10461            last_blocked_at_ms: None,
10462        }
10463    }
10464
10465    #[test]
10466    fn operator_session_record_family_requests_are_exact_round_trips() {
10467        let requests = vec![
10468            HarnessOperatorRequestV1::PreviewSessionRecord {
10469                node_id: "node-a".to_owned(),
10470                record_id: "record-a".to_owned(),
10471                message_limit: HARNESS_NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX,
10472            },
10473            HarnessOperatorRequestV1::ResumeSessionRecord {
10474                node_id: "node-a".to_owned(),
10475                record_id: "record-a".to_owned(),
10476                terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 },
10477                initial_prompt: Some("continue".to_owned()),
10478            },
10479            HarnessOperatorRequestV1::RenameSessionRecord {
10480                node_id: "node-a".to_owned(),
10481                record_id: "record-a".to_owned(),
10482                display_name: "Renamed session".to_owned(),
10483            },
10484            HarnessOperatorRequestV1::SetSessionTask {
10485                node_id: "node-a".to_owned(),
10486                record_id: "record-a".to_owned(),
10487                expected_revision: 3,
10488                target: HarnessSessionTaskTargetV1::Existing {
10489                    task_id: format!("task-{}", "a".repeat(24)),
10490                },
10491            },
10492            HarnessOperatorRequestV1::ForgetSessionRecord {
10493                node_id: "node-a".to_owned(),
10494                record_id: "record-a".to_owned(),
10495            },
10496            HarnessOperatorRequestV1::IndexProviderSession {
10497                node_id: "node-a".to_owned(),
10498                workspace_id: "workspace-a".to_owned(),
10499                provider: "codex".to_owned(),
10500                identity: HarnessProviderSessionIdentityV1 {
10501                    key: HarnessProviderSessionKeyV1::SessionId,
10502                    id: "session-123".to_owned(),
10503                    transcript_path: Some("/tmp/transcript.json".to_owned()),
10504                },
10505                display_name: "Indexed session".to_owned(),
10506            },
10507            HarnessOperatorRequestV1::IndexNativeSession {
10508                selection: sample_native_session_selection(),
10509                display_name: "Native indexed".to_owned(),
10510            },
10511        ];
10512        let credential = HarnessOperatorCredential::parse(format!(
10513            "g4aho_{}",
10514            "a".repeat(64),
10515        )).unwrap();
10516        for request in requests {
10517            request.validate().expect("valid session-record family request");
10518            let encoded = serde_json::to_string(&request).unwrap();
10519            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10520            assert_eq!(decoded, request);
10521            HarnessOperatorEnvelopeV1 {
10522                build_stamp: BUILD_STAMP.to_string(),
10523                credential: credential.clone(),
10524                request,
10525            }.validate().unwrap();
10526        }
10527    }
10528
10529    #[test]
10530    fn operator_session_record_family_responses_are_exact_round_trips() {
10531        let record = sample_managed_session("record-a");
10532        let responses = vec![
10533            HarnessOperatorResponseV1::SessionRecordPreviewed(HarnessSessionRecordPreviewedV1 {
10534                record_id: "record-a".to_owned(),
10535                preview: HarnessNativeSessionPreviewV1 {
10536                    title: Some("Preview title".to_owned()),
10537                    modified_at_unix_ms: Some(10),
10538                    model: Some("gpt".to_owned()),
10539                    message_count: 1,
10540                    message_count_exact: true,
10541                    completed_turn_count: Some(1),
10542                    total_tokens: Some(100),
10543                    truncated: false,
10544                    messages: vec![HarnessNativeSessionPreviewMessageV1 {
10545                        role: HarnessNativeSessionPreviewRoleV1::User,
10546                        text: "hello".to_owned(),
10547                    }],
10548                },
10549            }),
10550            HarnessOperatorResponseV1::SessionRecordResumed(HarnessSessionRecordResumedV1 {
10551                record: record.clone(),
10552                session: session_address(41, 3),
10553            }),
10554            HarnessOperatorResponseV1::SessionRecordUpdated(record.clone()),
10555            HarnessOperatorResponseV1::SessionRecordForgotten { record_id: "record-a".to_owned() },
10556            HarnessOperatorResponseV1::ProviderSessionIndexed(record.clone()),
10557            HarnessOperatorResponseV1::NativeSessionIndexed(HarnessNativeSessionIndexedV1 {
10558                selection: sample_native_session_selection(),
10559                record,
10560            }),
10561        ];
10562        for response in responses {
10563            response.validate().expect("valid session-record family response");
10564            let encoded = serde_json::to_string(&response).unwrap();
10565            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10566            assert_eq!(decoded, response);
10567        }
10568    }
10569
10570    #[test]
10571    fn session_record_family_requests_reject_malformed_fields() {
10572        let bad_node_id = HarnessOperatorRequestV1::PreviewSessionRecord {
10573            node_id: String::new(),
10574            record_id: "record-a".to_owned(),
10575            message_limit: 1,
10576        };
10577        assert!(matches!(
10578            bad_node_id.validate(),
10579            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10580        ));
10581
10582        let bad_limit = HarnessOperatorRequestV1::PreviewSessionRecord {
10583            node_id: "node-a".to_owned(),
10584            record_id: "record-a".to_owned(),
10585            message_limit: 0,
10586        };
10587        assert!(matches!(bad_limit.validate(), Err(HarnessOperatorApiError::InvalidLimit)));
10588
10589        let empty_display_name = HarnessOperatorRequestV1::RenameSessionRecord {
10590            node_id: "node-a".to_owned(),
10591            record_id: "record-a".to_owned(),
10592            display_name: String::new(),
10593        };
10594        assert!(matches!(
10595            empty_display_name.validate(),
10596            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10597        ));
10598
10599        let oversized_display_name = HarnessOperatorRequestV1::RenameSessionRecord {
10600            node_id: "node-a".to_owned(),
10601            record_id: "record-a".to_owned(),
10602            display_name: "x".repeat(HARNESS_SESSION_RECORD_DISPLAY_NAME_MAX_BYTES + 1),
10603        };
10604        assert!(matches!(
10605            oversized_display_name.validate(),
10606            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10607        ));
10608
10609        let bounded_display_name = HarnessOperatorRequestV1::RenameSessionRecord {
10610            node_id: "node-a".to_owned(),
10611            record_id: "record-a".to_owned(),
10612            display_name: "x".repeat(HARNESS_SESSION_RECORD_DISPLAY_NAME_MAX_BYTES),
10613        };
10614        bounded_display_name.validate().unwrap();
10615
10616        let zero_resume_size = HarnessOperatorRequestV1::ResumeSessionRecord {
10617            node_id: "node-a".to_owned(),
10618            record_id: "record-a".to_owned(),
10619            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 0, columns: 80 },
10620            initial_prompt: None,
10621        };
10622        assert!(matches!(
10623            zero_resume_size.validate(),
10624            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10625        ));
10626
10627        let empty_initial_prompt = HarnessOperatorRequestV1::ResumeSessionRecord {
10628            node_id: "node-a".to_owned(),
10629            record_id: "record-a".to_owned(),
10630            terminal_size: HarnessRuntimeTerminalSizeV1 { rows: 24, columns: 80 },
10631            initial_prompt: Some(String::new()),
10632        };
10633        assert!(matches!(
10634            empty_initial_prompt.validate(),
10635            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10636        ));
10637
10638        let bad_task_id = HarnessOperatorRequestV1::SetSessionTask {
10639            node_id: "node-a".to_owned(),
10640            record_id: "record-a".to_owned(),
10641            expected_revision: 1,
10642            target: HarnessSessionTaskTargetV1::Existing { task_id: String::new() },
10643        };
10644        assert!(matches!(
10645            bad_task_id.validate(),
10646            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10647        ));
10648
10649        let valid_clear_task = HarnessOperatorRequestV1::SetSessionTask {
10650            node_id: "node-a".to_owned(),
10651            record_id: "record-a".to_owned(),
10652            expected_revision: 1,
10653            target: HarnessSessionTaskTargetV1::Clear,
10654        };
10655        valid_clear_task.validate().unwrap();
10656
10657        let bad_identity_id = HarnessOperatorRequestV1::IndexProviderSession {
10658            node_id: "node-a".to_owned(),
10659            workspace_id: "workspace-a".to_owned(),
10660            provider: "codex".to_owned(),
10661            identity: HarnessProviderSessionIdentityV1 {
10662                key: HarnessProviderSessionKeyV1::SessionId,
10663                id: String::new(),
10664                transcript_path: None,
10665            },
10666            display_name: "Indexed session".to_owned(),
10667        };
10668        assert!(matches!(
10669            bad_identity_id.validate(),
10670            Err(HarnessOperatorApiError::InvalidSessionRecordRequest),
10671        ));
10672
10673        let mut malformed_selection = sample_native_session_selection();
10674        malformed_selection.catalog_revision = 0;
10675        let bad_index_native = HarnessOperatorRequestV1::IndexNativeSession {
10676            selection: malformed_selection.clone(),
10677            display_name: "Native indexed".to_owned(),
10678        };
10679        assert!(bad_index_native.validate().is_err());
10680        malformed_selection.catalog_revision = 7;
10681        HarnessOperatorRequestV1::IndexNativeSession {
10682            selection: malformed_selection,
10683            display_name: "Native indexed".to_owned(),
10684        }.validate().unwrap();
10685    }
10686
10687    #[test]
10688    fn session_record_family_responses_reject_malformed_fields() {
10689        let mut malformed_record = sample_managed_session("record-a");
10690        malformed_record.record_id = String::new();
10691        assert!(
10692            HarnessOperatorResponseV1::SessionRecordUpdated(malformed_record.clone())
10693                .validate()
10694                .is_err()
10695        );
10696        assert!(
10697            HarnessOperatorResponseV1::ProviderSessionIndexed(malformed_record.clone())
10698                .validate()
10699                .is_err()
10700        );
10701        assert!(
10702            HarnessOperatorResponseV1::SessionRecordResumed(HarnessSessionRecordResumedV1 {
10703                record: malformed_record,
10704                session: session_address(41, 3),
10705            }).validate().is_err()
10706        );
10707
10708        assert!(
10709            HarnessOperatorResponseV1::SessionRecordForgotten { record_id: String::new() }
10710                .validate()
10711                .is_err()
10712        );
10713
10714        let mut malformed_session = session_address(41, 3);
10715        malformed_session.instance_id = 0;
10716        assert!(
10717            HarnessOperatorResponseV1::SessionRecordResumed(HarnessSessionRecordResumedV1 {
10718                record: sample_managed_session("record-a"),
10719                session: malformed_session,
10720            }).validate().is_err()
10721        );
10722
10723        let mut malformed_selection = sample_native_session_selection();
10724        malformed_selection.selection_id = String::new();
10725        assert!(
10726            HarnessOperatorResponseV1::NativeSessionIndexed(HarnessNativeSessionIndexedV1 {
10727                selection: malformed_selection,
10728                record: sample_managed_session("record-a"),
10729            }).validate().is_err()
10730        );
10731    }
10732
10733    fn host_path(value: &str) -> HarnessHostPathV1 {
10734        HarnessHostPathV1::new(value).unwrap()
10735    }
10736
10737    fn sample_workspace_snapshot(workspace_id: &str) -> HarnessWorkspaceSnapshotV1 {
10738        HarnessWorkspaceSnapshotV1 {
10739            workspace_id: workspace_id.to_owned(),
10740            canonical_root: host_path(r"C:\fixtures\workspace"),
10741            worktree_service_mode: Some(HarnessWorktreeServiceModeV1::Manual),
10742        }
10743    }
10744
10745    fn sample_git_worktree_snapshot() -> HarnessGitWorktreeSnapshotV1 {
10746        HarnessGitWorktreeSnapshotV1 {
10747            path: host_path(r"C:\fixtures\worktree"),
10748            head: "a".repeat(40),
10749            branch: Some("feature/resource-mutation".to_owned()),
10750            is_bare: false,
10751            is_main: false,
10752            locked: false,
10753            prunable: false,
10754            workspace_id: Some("worktree-workspace".to_owned()),
10755        }
10756    }
10757
10758    #[test]
10759    fn operator_resource_mutation_family_requests_are_exact_round_trips() {
10760        let requests = vec![
10761            HarnessOperatorRequestV1::BrowseHostDirectories {
10762                node_id: "node-a".to_owned(),
10763                directory: Some(host_path(r"C:\fixtures")),
10764                after: Some(host_path(r"C:\fixtures\a")),
10765            },
10766            HarnessOperatorRequestV1::RegisterWorkspace {
10767                node_id: "node-a".to_owned(),
10768                workspace_id: "workspace-b".to_owned(),
10769                root: host_path(r"C:\fixtures\workspace-b"),
10770            },
10771            HarnessOperatorRequestV1::UnregisterWorkspace {
10772                node_id: "node-a".to_owned(),
10773                workspace_id: "workspace-b".to_owned(),
10774            },
10775            HarnessOperatorRequestV1::CreateStandaloneWorkspace {
10776                node_id: "node-a".to_owned(),
10777                workspace_id: "workspace-c".to_owned(),
10778                root: host_path(r"C:\fixtures\workspace-c"),
10779                initial_branch: Some("main".to_owned()),
10780            },
10781            HarnessOperatorRequestV1::CreateWorktree {
10782                node_id: "node-a".to_owned(),
10783                source_workspace_id: "workspace-a".to_owned(),
10784                workspace_id: "worktree-workspace".to_owned(),
10785                target_root: host_path(r"C:\fixtures\worktree"),
10786                branch: "feature/resource-mutation".to_owned(),
10787                base: Some("main".to_owned()),
10788            },
10789            HarnessOperatorRequestV1::RemoveWorktree {
10790                node_id: "node-a".to_owned(),
10791                source_workspace_id: "workspace-a".to_owned(),
10792                target_root: host_path(r"C:\fixtures\worktree"),
10793            },
10794            HarnessOperatorRequestV1::ExportContextPack { session: session_address(41, 3) },
10795            HarnessOperatorRequestV1::ForgetContextPack {
10796                node_id: "node-a".to_owned(),
10797                context_id: HarnessSelectorV1::new("context-a").unwrap(),
10798            },
10799        ];
10800        let credential = HarnessOperatorCredential::parse(format!(
10801            "g4aho_{}",
10802            "b".repeat(64),
10803        )).unwrap();
10804        for request in requests {
10805            request.validate().expect("valid resource-mutation family request");
10806            let encoded = serde_json::to_string(&request).unwrap();
10807            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
10808            assert_eq!(decoded, request);
10809            HarnessOperatorEnvelopeV1 {
10810                build_stamp: BUILD_STAMP.to_string(),
10811                credential: credential.clone(),
10812                request,
10813            }.validate().unwrap();
10814        }
10815    }
10816
10817    #[test]
10818    fn operator_resource_mutation_family_responses_are_exact_round_trips() {
10819        let responses = vec![
10820            HarnessOperatorResponseV1::HostDirectoriesBrowsed(HarnessHostDirectoryListingV1 {
10821                directory: Some(host_path(r"C:\fixtures")),
10822                parent: Some(host_path(r"C:\")),
10823                entries: vec![HarnessHostDirectoryEntryV1 {
10824                    path: host_path(r"C:\fixtures\workspace"),
10825                    display_name: "workspace".to_owned(),
10826                    is_link: false,
10827                }],
10828                next_after: Some(host_path(r"C:\fixtures\workspace")),
10829                incomplete: true,
10830            }),
10831            HarnessOperatorResponseV1::WorkspaceRegistered(sample_workspace_snapshot("workspace-b")),
10832            HarnessOperatorResponseV1::StandaloneWorkspaceCreated(
10833                sample_workspace_snapshot("workspace-c"),
10834            ),
10835            HarnessOperatorResponseV1::WorkspaceUnregistered {
10836                workspace_id: "workspace-b".to_owned(),
10837            },
10838            HarnessOperatorResponseV1::WorktreeCreated {
10839                worktree: sample_git_worktree_snapshot(),
10840                workspace: sample_workspace_snapshot("worktree-workspace"),
10841            },
10842            HarnessOperatorResponseV1::WorktreeRemoved {
10843                target_root: host_path(r"C:\fixtures\worktree"),
10844                workspace_id: Some("worktree-workspace".to_owned()),
10845            },
10846            HarnessOperatorResponseV1::ContextPackExported(HarnessResolvedContextPackReceiptV1 {
10847                id: HarnessSelectorV1::new("context-a").unwrap(),
10848                digest: "sha256:".to_owned() + &"a".repeat(64),
10849                lineage: HarnessContextPackLineageV1 {
10850                    source_node_id: HarnessSelectorV1::new("node-a").unwrap(),
10851                    source_workspace_id: HarnessSelectorV1::new("workspace-a").unwrap(),
10852                    source_instance_id: 41,
10853                    source_generation: 3,
10854                    source_provider: HarnessSelectorV1::new("codex").unwrap(),
10855                },
10856                source_message_count: 4,
10857                retained_message_count: 4,
10858                byte_len: 128,
10859                truncated: false,
10860            }),
10861            HarnessOperatorResponseV1::ContextPackForgotten { context_id: "context-a".to_owned() },
10862        ];
10863        for response in responses {
10864            response.validate().expect("valid resource-mutation family response");
10865            let encoded = serde_json::to_string(&response).unwrap();
10866            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
10867            assert_eq!(decoded, response);
10868        }
10869    }
10870
10871    #[test]
10872    fn resource_mutation_family_requests_reject_malformed_fields() {
10873        let bad_node_id = HarnessOperatorRequestV1::BrowseHostDirectories {
10874            node_id: String::new(),
10875            directory: None,
10876            after: None,
10877        };
10878        assert!(matches!(
10879            bad_node_id.validate(),
10880            Err(HarnessOperatorApiError::InvalidHostDirectoryBrowseRequest),
10881        ));
10882
10883        assert!(matches!(
10884            HarnessHostPathV1::new(""),
10885            Err(HarnessOperatorApiError::InvalidHostPath),
10886        ));
10887        assert!(matches!(
10888            HarnessHostPathV1::new("x".repeat(HARNESS_HOST_PATH_MAX_BYTES + 1)),
10889            Err(HarnessOperatorApiError::InvalidHostPath),
10890        ));
10891        HarnessHostPathV1::new("x".repeat(HARNESS_HOST_PATH_MAX_BYTES)).unwrap();
10892
10893        let bad_workspace_id = HarnessOperatorRequestV1::RegisterWorkspace {
10894            node_id: "node-a".to_owned(),
10895            workspace_id: String::new(),
10896            root: host_path(r"C:\fixtures\workspace-b"),
10897        };
10898        assert!(matches!(
10899            bad_workspace_id.validate(),
10900            Err(HarnessOperatorApiError::InvalidResourceMutationRequest),
10901        ));
10902
10903        let empty_branch = HarnessOperatorRequestV1::CreateWorktree {
10904            node_id: "node-a".to_owned(),
10905            source_workspace_id: "workspace-a".to_owned(),
10906            workspace_id: "worktree-workspace".to_owned(),
10907            target_root: host_path(r"C:\fixtures\worktree"),
10908            branch: String::new(),
10909            base: None,
10910        };
10911        assert!(matches!(
10912            empty_branch.validate(),
10913            Err(HarnessOperatorApiError::InvalidResourceMutationRequest),
10914        ));
10915
10916        let control_char_base = HarnessOperatorRequestV1::CreateWorktree {
10917            node_id: "node-a".to_owned(),
10918            source_workspace_id: "workspace-a".to_owned(),
10919            workspace_id: "worktree-workspace".to_owned(),
10920            target_root: host_path(r"C:\fixtures\worktree"),
10921            branch: "feature/resource-mutation".to_owned(),
10922            base: Some("main\u{0007}".to_owned()),
10923        };
10924        assert!(matches!(
10925            control_char_base.validate(),
10926            Err(HarnessOperatorApiError::InvalidResourceMutationRequest),
10927        ));
10928
10929        let mut malformed_session = session_address(41, 3);
10930        malformed_session.instance_id = 0;
10931        assert!(
10932            HarnessOperatorRequestV1::ExportContextPack { session: malformed_session }
10933                .validate()
10934                .is_err()
10935        );
10936
10937        let bad_context_id = HarnessOperatorRequestV1::ForgetContextPack {
10938            node_id: "node-a".to_owned(),
10939            context_id: HarnessSelectorV1::new("context-a").unwrap(),
10940        };
10941        bad_context_id.validate().unwrap();
10942        let empty_forget_node_id = HarnessOperatorRequestV1::ForgetContextPack {
10943            node_id: String::new(),
10944            context_id: HarnessSelectorV1::new("context-a").unwrap(),
10945        };
10946        assert!(matches!(
10947            empty_forget_node_id.validate(),
10948            Err(HarnessOperatorApiError::InvalidResourceMutationRequest),
10949        ));
10950    }
10951
10952    #[test]
10953    fn resource_mutation_family_responses_reject_malformed_fields() {
10954        assert!(
10955            HarnessOperatorResponseV1::WorkspaceUnregistered { workspace_id: String::new() }
10956                .validate()
10957                .is_err()
10958        );
10959
10960        let mut malformed_workspace = sample_workspace_snapshot("workspace-b");
10961        malformed_workspace.workspace_id = String::new();
10962        assert!(
10963            HarnessOperatorResponseV1::WorkspaceRegistered(malformed_workspace)
10964                .validate()
10965                .is_err()
10966        );
10967
10968        let mut malformed_worktree = sample_git_worktree_snapshot();
10969        malformed_worktree.head = String::new();
10970        assert!(
10971            HarnessOperatorResponseV1::WorktreeCreated {
10972                worktree: malformed_worktree,
10973                workspace: sample_workspace_snapshot("worktree-workspace"),
10974            }.validate().is_err()
10975        );
10976
10977        assert!(
10978            HarnessOperatorResponseV1::WorktreeRemoved {
10979                target_root: host_path(r"C:\fixtures\worktree"),
10980                workspace_id: Some(String::new()),
10981            }.validate().is_err()
10982        );
10983
10984        assert!(
10985            HarnessOperatorResponseV1::ContextPackForgotten { context_id: String::new() }
10986                .validate()
10987                .is_err()
10988        );
10989    }
10990
10991    fn sample_redacted_task(id_byte: char) -> RedactedTaskV1 {
10992        RedactedTaskV1 {
10993            task_id: HarnessTaskId::new(format!("htask_{}", id_byte.to_string().repeat(24)))
10994                .unwrap(),
10995            revision: HarnessRevision::new(1).unwrap(),
10996            title: "Task title".to_owned(),
10997            body: String::new(),
10998            creator: TaskCreatorCategoryV1::User,
10999            parent_task_id: None,
11000            dependency_ids: Vec::new(),
11001            state: HarnessTaskStateV1::Backlog,
11002            run_ids: Vec::new(),
11003            references_redacted: false,
11004            result_refs: Vec::new(),
11005            artifact_refs: Vec::new(),
11006            created_at_unix_ms: 10,
11007            updated_at_unix_ms: 10,
11008        }
11009    }
11010
11011    fn sample_redacted_run(id_byte: char) -> RedactedRunV1 {
11012        RedactedRunV1 {
11013            run_id: HarnessRunId::new(format!("hrun_{}", id_byte.to_string().repeat(24)))
11014                .unwrap(),
11015            revision: HarnessRevision::new(1).unwrap(),
11016            parent_run_id: None,
11017            task_id: None,
11018            operation_id: None,
11019            intent: RedactedRunIntentV1 {
11020                mode: HarnessExecutionModeV1::Pty,
11021                worktree: RedactedWorktreeIntentV1::Existing,
11022                has_delivery_bundle: false,
11023                has_continuation: false,
11024            },
11025            lifecycle: HarnessRunLifecycleV1::Requested,
11026            binding: RedactedBindingStateV1::None,
11027            result_disposition: None,
11028            failure_category: None,
11029            context_pack: None,
11030            git_facts: None,
11031            delivery_receipt: None,
11032            references_redacted: false,
11033            created_at_unix_ms: 10,
11034            updated_at_unix_ms: 10,
11035        }
11036    }
11037
11038    fn simple_node_inventory(node_id: &str) -> HarnessRuntimeNodeInventoryV1 {
11039        HarnessRuntimeNodeInventoryV1 {
11040            node_id: node_id.to_owned(),
11041            incarnation_id: "07".repeat(16),
11042            observed_at_unix_ms: 1_000,
11043            event_sequence: 1,
11044            inventory: HarnessRuntimeInventoryV1 {
11045                enabled_providers: Vec::new(),
11046                workspaces: BTreeMap::new(),
11047                workspace_count: 0,
11048                workspaces_truncated: false,
11049                session_count: 0,
11050                sessions_truncated: false,
11051                managed_sessions: Vec::new(),
11052                managed_session_count: 0,
11053                managed_sessions_truncated: false,
11054                retired_count: 0,
11055                launch_inventory: None,
11056
11057            machine: None,},
11058        }
11059    }
11060
11061    #[test]
11062    fn operator_subscribe_events_is_exact_round_trip() {
11063        let request = HarnessOperatorRequestV1::SubscribeEvents {};
11064        request.validate().expect("valid subscribe request");
11065        let encoded = serde_json::to_string(&request).unwrap();
11066        let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
11067        assert_eq!(decoded, request);
11068
11069        let credential = HarnessOperatorCredential::parse(format!(
11070            "g4aho_{}",
11071            "a".repeat(64),
11072        )).unwrap();
11073        HarnessOperatorEnvelopeV1 {
11074            build_stamp: BUILD_STAMP.to_string(),
11075            credential,
11076            request,
11077        }.validate().unwrap();
11078    }
11079
11080    #[test]
11081    fn operator_subscribe_terminal_is_exact_round_trip() {
11082        let request = HarnessOperatorRequestV1::SubscribeTerminal {
11083            sessions: vec![session_address(1, 1), session_address(2, 1)],
11084        };
11085        request.validate().expect("valid subscribe-terminal request");
11086        let encoded = serde_json::to_string(&request).unwrap();
11087        let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
11088        assert_eq!(decoded, request);
11089
11090        let credential = HarnessOperatorCredential::parse(format!(
11091            "g4aho_{}",
11092            "a".repeat(64),
11093        )).unwrap();
11094        HarnessOperatorEnvelopeV1 {
11095            build_stamp: BUILD_STAMP.to_string(),
11096            credential,
11097            request,
11098        }.validate().unwrap();
11099    }
11100
11101    /// The single accepted build stamp: a foreign stamp is rejected, and the
11102    /// error names both the expected and the received stamp -- the
11103    /// diagnostic a silent binary-skew incident previously had neither of.
11104    #[test]
11105    fn envelope_with_a_foreign_build_stamp_is_rejected_naming_both_values() {
11106        let credential = HarnessOperatorCredential::parse(format!(
11107            "g4aho_{}",
11108            "a".repeat(64),
11109        )).unwrap();
11110        let foreign_stamp = "f".repeat(40);
11111        let envelope = HarnessOperatorEnvelopeV1 {
11112            build_stamp: foreign_stamp.clone(),
11113            credential,
11114            request: HarnessOperatorRequestV1::SubscribeEvents {},
11115        };
11116        let error = envelope
11117            .validate()
11118            .expect_err("a foreign build stamp must be rejected");
11119        assert!(matches!(
11120            &error,
11121            HarnessOperatorApiError::BuildStampMismatch { expected, received }
11122                if expected == BUILD_STAMP && received == &foreign_stamp,
11123        ));
11124        let message = error.to_string();
11125        assert!(message.contains(BUILD_STAMP));
11126        assert!(message.contains(&foreign_stamp));
11127    }
11128
11129    #[test]
11130    fn subscribe_terminal_rejects_empty_oversized_and_duplicate_session_lists() {
11131        assert!(matches!(
11132            HarnessOperatorRequestV1::SubscribeTerminal { sessions: Vec::new() }.validate(),
11133            Err(HarnessOperatorApiError::InvalidTerminalPage),
11134        ));
11135
11136        let oversized = (0..=HARNESS_TERMINAL_SUBSCRIPTION_SESSIONS_MAX as u64)
11137            .map(|instance_id| session_address(instance_id + 1, 1))
11138            .collect::<Vec<_>>();
11139        assert!(matches!(
11140            HarnessOperatorRequestV1::SubscribeTerminal { sessions: oversized }.validate(),
11141            Err(HarnessOperatorApiError::InvalidTerminalPage),
11142        ));
11143
11144        let duplicated = vec![session_address(1, 1), session_address(1, 1)];
11145        assert!(matches!(
11146            HarnessOperatorRequestV1::SubscribeTerminal { sessions: duplicated }.validate(),
11147            Err(HarnessOperatorApiError::InvalidTerminalPage),
11148        ));
11149
11150        let mut malformed = session_address(1, 1);
11151        malformed.node_id = String::new();
11152        assert!(HarnessOperatorRequestV1::SubscribeTerminal { sessions: vec![malformed] }
11153            .validate()
11154            .is_err());
11155    }
11156
11157    #[test]
11158    fn operator_event_variants_are_exact_round_trips() {
11159        let task = sample_redacted_task('a');
11160        let run = sample_redacted_run('b');
11161        let node = simple_node_inventory("node-a");
11162        let events = vec![
11163            HarnessOperatorEventV1::SnapshotBaseline {
11164                sequence: 0,
11165                tasks: vec![task.clone()],
11166                runs: vec![run.clone()],
11167                nodes: vec![node.clone()],
11168            },
11169            HarnessOperatorEventV1::TaskChanged { sequence: 1, task: task.clone() },
11170            HarnessOperatorEventV1::RunChanged { sequence: 2, run: run.clone() },
11171            HarnessOperatorEventV1::RuntimeInventoryChanged { sequence: 3, node: node.clone() },
11172            HarnessOperatorEventV1::RuntimeInventoryRemoved {
11173                sequence: 4,
11174                node_id: "node-a".to_owned(),
11175            },
11176            HarnessOperatorEventV1::Lagged { sequence: 5 },
11177        ];
11178        for event in events {
11179            event.validate().expect("valid operator event");
11180            let encoded = serde_json::to_string(&event).unwrap();
11181            let decoded: HarnessOperatorEventV1 = serde_json::from_str(&encoded).unwrap();
11182            assert_eq!(decoded, event);
11183        }
11184    }
11185
11186    #[test]
11187    fn operator_terminal_event_variants_are_exact_round_trips() {
11188        let events = vec![
11189            HarnessOperatorTerminalEventV1::TerminalFrame {
11190                sequence: 0,
11191                session: session_address(1, 1),
11192                frame: sample_terminal_frame(1),
11193                coalesced_since_last: 0,
11194            },
11195            HarnessOperatorTerminalEventV1::TerminalFrame {
11196                sequence: 1,
11197                session: session_address(1, 1),
11198                frame: sample_terminal_frame(2),
11199                coalesced_since_last: 3,
11200            },
11201            HarnessOperatorTerminalEventV1::Ping { sequence: 2 },
11202        ];
11203        for event in events {
11204            event.validate().expect("valid operator terminal event");
11205            let encoded = serde_json::to_string(&event).unwrap();
11206            let decoded: HarnessOperatorTerminalEventV1 = serde_json::from_str(&encoded).unwrap();
11207            assert_eq!(decoded, event);
11208        }
11209    }
11210
11211    #[test]
11212    fn operator_terminal_event_validate_rejects_a_malformed_frame_or_session() {
11213        let mut bad_session = session_address(1, 1);
11214        bad_session.node_id = String::new();
11215        let bad_session_event = HarnessOperatorTerminalEventV1::TerminalFrame {
11216            sequence: 0,
11217            session: bad_session,
11218            frame: sample_terminal_frame(1),
11219            coalesced_since_last: 0,
11220        };
11221        assert!(matches!(
11222            bad_session_event.validate(),
11223            Err(HarnessOperatorApiError::InvalidTerminalPage),
11224        ));
11225
11226        let mut zero_size_frame = sample_terminal_frame(1);
11227        zero_size_frame.size.rows = 0;
11228        let bad_frame_event = HarnessOperatorTerminalEventV1::TerminalFrame {
11229            sequence: 0,
11230            session: session_address(1, 1),
11231            frame: zero_size_frame,
11232            coalesced_since_last: 0,
11233        };
11234        assert!(matches!(
11235            bad_frame_event.validate(),
11236            Err(HarnessOperatorApiError::InvalidTerminalPage),
11237        ));
11238    }
11239
11240    #[test]
11241    fn operator_event_validate_rejects_malformed_frames() {
11242        let task_a = sample_redacted_task('a');
11243        let task_b = sample_redacted_task('b');
11244        let out_of_order = HarnessOperatorEventV1::SnapshotBaseline {
11245            sequence: 0,
11246            tasks: vec![task_b, task_a],
11247            runs: Vec::new(),
11248            nodes: Vec::new(),
11249        };
11250        assert!(out_of_order.validate().is_err());
11251
11252        let bad_node_id = HarnessOperatorEventV1::RuntimeInventoryRemoved {
11253            sequence: 0,
11254            node_id: String::new(),
11255        };
11256        assert!(matches!(
11257            bad_node_id.validate(),
11258            Err(HarnessOperatorApiError::InvalidRuntimeInventory),
11259        ));
11260
11261        let mut malformed_task = sample_redacted_task('c');
11262        malformed_task.title = "\0".to_owned();
11263        let bad_task = HarnessOperatorEventV1::TaskChanged { sequence: 0, task: malformed_task };
11264        assert!(bad_task.validate().is_err());
11265    }
11266
11267    /// `HarnessOperatorHostErrorV1::Unsupported` (added alongside
11268    /// `hatchery-harness-light`, see its own doc comment): a purely
11269    /// additive unit variant, so this only needs to prove it round-trips --
11270    /// on its own, and wrapped in the `HarnessOperatorReplyV1::Error` shape
11271    /// every operator host error actually rides on the wire.
11272    #[test]
11273    fn operator_host_error_unsupported_round_trips() {
11274        let error = HarnessOperatorHostErrorV1::Unsupported;
11275        let encoded = serde_json::to_string(&error).unwrap();
11276        assert_eq!(encoded, "\"unsupported\"");
11277        assert_eq!(serde_json::from_str::<HarnessOperatorHostErrorV1>(&encoded).unwrap(), error);
11278
11279        let reply = HarnessOperatorReplyV1::Error { error };
11280        assert!(reply.validate().is_ok());
11281        let encoded_reply = serde_json::to_vec(&reply).unwrap();
11282        let decoded_reply: HarnessOperatorReplyV1 = serde_json::from_slice(&encoded_reply).unwrap();
11283        assert_eq!(decoded_reply, reply);
11284    }
11285
11286    #[test]
11287    fn operator_acp_control_verbs_are_exact_round_trips() {
11288        let session = session_address(7, 2);
11289        let requests = vec![
11290            HarnessOperatorRequestV1::ResolveInteraction {
11291                session: session.clone(),
11292                correlation_id: "a".repeat(HARNESS_OBSERVATION_LABEL_MAX_BYTES),
11293                response: HarnessProviderInteractionResponseV1::ApproveOnce,
11294            },
11295            HarnessOperatorRequestV1::ResolveInteraction {
11296                session: session.clone(),
11297                correlation_id: "correlation-1".to_owned(),
11298                response: HarnessProviderInteractionResponseV1::Deny,
11299            },
11300            HarnessOperatorRequestV1::ResolveInteraction {
11301                session: session.clone(),
11302                correlation_id: "correlation-2".to_owned(),
11303                response: HarnessProviderInteractionResponseV1::Answer {
11304                    text: "yes, proceed".to_owned(),
11305                },
11306            },
11307            HarnessOperatorRequestV1::SetSessionMode {
11308                session: session.clone(),
11309                mode_id: "plan".to_owned(),
11310            },
11311            HarnessOperatorRequestV1::SetSessionConfigOption {
11312                session: session.clone(),
11313                option_id: "reasoning-effort".to_owned(),
11314                value_json: "\"high\"".to_owned(),
11315            },
11316            HarnessOperatorRequestV1::SetSessionModel {
11317                session: session.clone(),
11318                model_id: "grok-4".to_owned(),
11319            },
11320        ];
11321        let credential = HarnessOperatorCredential::parse(format!(
11322            "g4aho_{}",
11323            "a".repeat(64),
11324        )).unwrap();
11325        for request in requests {
11326            request.validate().expect("valid ACP control verb request");
11327            let encoded = serde_json::to_string(&request).unwrap();
11328            let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
11329            assert_eq!(decoded, request);
11330            HarnessOperatorEnvelopeV1 {
11331                build_stamp: BUILD_STAMP.to_string(),
11332                credential: credential.clone(),
11333                request,
11334            }.validate().unwrap();
11335        }
11336
11337        let responses = vec![
11338            HarnessOperatorResponseV1::InteractionResolved,
11339            HarnessOperatorResponseV1::SessionModeSet,
11340            HarnessOperatorResponseV1::SessionConfigOptionSet,
11341            HarnessOperatorResponseV1::SessionModelSet,
11342        ];
11343        for response in responses {
11344            response.validate().expect("valid ACP control verb response");
11345            let encoded = serde_json::to_string(&response).unwrap();
11346            let decoded: HarnessOperatorResponseV1 = serde_json::from_str(&encoded).unwrap();
11347            assert_eq!(decoded, response);
11348        }
11349    }
11350
11351    #[test]
11352    fn acp_control_requests_reject_malformed_fields() {
11353        let session = session_address(7, 2);
11354
11355        let empty_correlation = HarnessOperatorRequestV1::ResolveInteraction {
11356            session: session.clone(),
11357            correlation_id: String::new(),
11358            response: HarnessProviderInteractionResponseV1::ApproveOnce,
11359        };
11360        assert!(matches!(
11361            empty_correlation.validate(),
11362            Err(HarnessOperatorApiError::InvalidSessionControl),
11363        ));
11364
11365        let oversized_correlation = HarnessOperatorRequestV1::ResolveInteraction {
11366            session: session.clone(),
11367            correlation_id: "a".repeat(HARNESS_OBSERVATION_LABEL_MAX_BYTES + 1),
11368            response: HarnessProviderInteractionResponseV1::Deny,
11369        };
11370        assert!(matches!(
11371            oversized_correlation.validate(),
11372            Err(HarnessOperatorApiError::InvalidSessionControl),
11373        ));
11374
11375        let oversized_answer = HarnessOperatorRequestV1::ResolveInteraction {
11376            session: session.clone(),
11377            correlation_id: "correlation-1".to_owned(),
11378            response: HarnessProviderInteractionResponseV1::Answer {
11379                text: "x".repeat(HARNESS_ACP_INTERACTION_RESPONSE_MAX_BYTES + 1),
11380            },
11381        };
11382        assert!(matches!(
11383            oversized_answer.validate(),
11384            Err(HarnessOperatorApiError::InvalidSessionControl),
11385        ));
11386
11387        let empty_mode_id = HarnessOperatorRequestV1::SetSessionMode {
11388            session: session.clone(),
11389            mode_id: String::new(),
11390        };
11391        assert!(matches!(
11392            empty_mode_id.validate(),
11393            Err(HarnessOperatorApiError::InvalidSessionControl),
11394        ));
11395
11396        let control_char_option_id = HarnessOperatorRequestV1::SetSessionConfigOption {
11397            session: session.clone(),
11398            option_id: "bad\u{0007}id".to_owned(),
11399            value_json: "true".to_owned(),
11400        };
11401        assert!(matches!(
11402            control_char_option_id.validate(),
11403            Err(HarnessOperatorApiError::InvalidSessionControl),
11404        ));
11405
11406        let empty_value_json = HarnessOperatorRequestV1::SetSessionConfigOption {
11407            session: session.clone(),
11408            option_id: "reasoning-effort".to_owned(),
11409            value_json: String::new(),
11410        };
11411        assert!(matches!(
11412            empty_value_json.validate(),
11413            Err(HarnessOperatorApiError::InvalidSessionControl),
11414        ));
11415
11416        let oversized_model_id = HarnessOperatorRequestV1::SetSessionModel {
11417            session: session.clone(),
11418            model_id: "x".repeat(HARNESS_AGENT_STREAM_ID_MAX_BYTES + 1),
11419        };
11420        assert!(matches!(
11421            oversized_model_id.validate(),
11422            Err(HarnessOperatorApiError::InvalidSessionControl),
11423        ));
11424
11425        let mut malformed_session = session.clone();
11426        malformed_session.instance_id = 0;
11427        let bad_session = HarnessOperatorRequestV1::SetSessionModel {
11428            session: malformed_session,
11429            model_id: "grok-4".to_owned(),
11430        };
11431        assert!(bad_session.validate().is_err());
11432    }
11433
11434    #[test]
11435    fn operator_subscribe_agent_stream_is_exact_round_trip() {
11436        let request = HarnessOperatorRequestV1::SubscribeAgentStream {
11437            sessions: vec![session_address(1, 1), session_address(2, 1)],
11438        };
11439        request.validate().expect("valid subscribe-agent-stream request");
11440        let encoded = serde_json::to_string(&request).unwrap();
11441        let decoded: HarnessOperatorRequestV1 = serde_json::from_str(&encoded).unwrap();
11442        assert_eq!(decoded, request);
11443
11444        let credential = HarnessOperatorCredential::parse(format!(
11445            "g4aho_{}",
11446            "a".repeat(64),
11447        )).unwrap();
11448        HarnessOperatorEnvelopeV1 {
11449            build_stamp: BUILD_STAMP.to_string(),
11450            credential,
11451            request,
11452        }.validate().unwrap();
11453    }
11454
11455    #[test]
11456    fn subscribe_agent_stream_rejects_empty_oversized_and_duplicate_session_lists() {
11457        assert!(matches!(
11458            HarnessOperatorRequestV1::SubscribeAgentStream { sessions: Vec::new() }.validate(),
11459            Err(HarnessOperatorApiError::InvalidAgentStream),
11460        ));
11461
11462        let oversized = (0..=HARNESS_AGENT_STREAM_SUBSCRIPTION_SESSIONS_MAX as u64)
11463            .map(|instance_id| session_address(instance_id + 1, 1))
11464            .collect::<Vec<_>>();
11465        assert!(matches!(
11466            HarnessOperatorRequestV1::SubscribeAgentStream { sessions: oversized }.validate(),
11467            Err(HarnessOperatorApiError::InvalidAgentStream),
11468        ));
11469
11470        let duplicated = vec![session_address(1, 1), session_address(1, 1)];
11471        assert!(matches!(
11472            HarnessOperatorRequestV1::SubscribeAgentStream { sessions: duplicated }.validate(),
11473            Err(HarnessOperatorApiError::InvalidAgentStream),
11474        ));
11475
11476        let mut malformed = session_address(1, 1);
11477        malformed.node_id = String::new();
11478        assert!(HarnessOperatorRequestV1::SubscribeAgentStream { sessions: vec![malformed] }
11479            .validate()
11480            .is_err());
11481    }
11482
11483    #[test]
11484    fn operator_agent_event_variants_are_exact_round_trips() {
11485        let session = session_address(1, 1);
11486        let chunks = vec![
11487            HarnessAgentStreamChunkKindV1::Text { text: "hello".to_owned(), is_delta: false },
11488            HarnessAgentStreamChunkKindV1::Thinking { text: "considering options".to_owned() },
11489            HarnessAgentStreamChunkKindV1::InteractionPrompt {
11490                correlation_id: "correlation-1".to_owned(),
11491                interaction_kind: HarnessProviderInteractionKindV1::Approval,
11492                tool_name: "execute".to_owned(),
11493                title: Some("Run command?".to_owned()),
11494                prompt: "rm -rf /tmp/scratch".to_owned(),
11495                options: vec![HarnessAgentStreamInteractionOptionV1 {
11496                    option_id: "allow".to_owned(),
11497                    name: "Allow".to_owned(),
11498                    kind: "allow_once".to_owned(),
11499                }],
11500            },
11501            HarnessAgentStreamChunkKindV1::ModeCatalog {
11502                current: Some("plan".to_owned()),
11503                available: vec![HarnessAgentStreamNamedIdV1 {
11504                    id: "plan".to_owned(),
11505                    name: "Plan".to_owned(),
11506                    description: None,
11507                }],
11508            },
11509            HarnessAgentStreamChunkKindV1::ConfigOptions {
11510                options: vec![HarnessProviderConfigOptionV1 {
11511                    id: "reasoning-effort".to_owned(),
11512                    name: "Reasoning effort".to_owned(),
11513                    description: Some("How hard to think".to_owned()),
11514                    category: Some("model".to_owned()),
11515                    kind: HarnessProviderConfigOptionKindV1::Select,
11516                    value_json: "\"high\"".to_owned(),
11517                    choices: vec![HarnessProviderConfigChoiceV1 {
11518                        value_json: "\"high\"".to_owned(),
11519                        label: Some("High".to_owned()),
11520                    }],
11521                }],
11522            },
11523            HarnessAgentStreamChunkKindV1::ModelCatalog {
11524                current: Some("grok-4".to_owned()),
11525                available: vec![HarnessAgentStreamNamedIdV1 {
11526                    id: "grok-4".to_owned(),
11527                    name: "Grok 4".to_owned(),
11528                    description: None,
11529                }],
11530            },
11531        ];
11532
11533        let mut events: Vec<HarnessOperatorAgentEventV1> = chunks
11534            .into_iter()
11535            .enumerate()
11536            .map(|(index, kind)| HarnessOperatorAgentEventV1::AgentChunk {
11537                sequence: index as u64,
11538                session: session.clone(),
11539                chunk: HarnessAgentStreamChunkV1 { source_sequence: index as u64, kind },
11540                published_at_ms: 1_000 + index as u64,
11541            })
11542            .collect();
11543        events.push(HarnessOperatorAgentEventV1::Lagged {
11544            sequence: 100,
11545            session: session.clone(),
11546            dropped: 3,
11547        });
11548        events.push(HarnessOperatorAgentEventV1::ReplayBoundary {
11549            session: session.clone(),
11550            replayed: 6,
11551            dropped_before_replay: 44,
11552        });
11553        events.push(HarnessOperatorAgentEventV1::Ping { sequence: 101 });
11554
11555        for event in events {
11556            event.validate().expect("valid operator agent event");
11557            let encoded = serde_json::to_string(&event).unwrap();
11558            let decoded: HarnessOperatorAgentEventV1 = serde_json::from_str(&encoded).unwrap();
11559            assert_eq!(decoded, event);
11560        }
11561    }
11562
11563    /// `TasksList`/`RunsList` gain a parent filter ("what did this task/run
11564    /// spawn") additive over the pre-existing shape via `#[serde(default)]`:
11565    /// an old-shape request with no `parent_task_id`/`parent_run_id` key
11566    /// still decodes (defaulting to `None`), and a set value round-trips.
11567    /// A malformed id can never reach `parent_task_id`/
11568    /// `parent_run_id` in the first place -- `HarnessTaskId::new`/
11569    /// `HarnessRunId::new` are the validation, refusing a malformed string
11570    /// before a request carrying one could ever be built (the CLI's own
11571    /// rejection of a malformed `--parent`/`--parent-run` flag, tested in
11572    /// `hatchery-harnessctl`, goes through this exact constructor).
11573    #[test]
11574    fn parent_filters_default_on_old_shape_and_round_trip() {
11575        let task_id = HarnessTaskId::new(format!("htask_{}", "a".repeat(24))).unwrap();
11576        let tasks_request = HarnessReadRequestV1::TasksList {
11577            after_task_id: None,
11578            state: None,
11579            parent_task_id: Some(task_id.clone()),
11580            limit: 10,
11581        };
11582        tasks_request.validate().unwrap();
11583        let encoded_tasks = serde_json::to_string(&tasks_request).unwrap();
11584        assert_eq!(
11585            serde_json::from_str::<HarnessReadRequestV1>(&encoded_tasks).unwrap(),
11586            tasks_request,
11587        );
11588
11589        let old_shape_tasks = serde_json::json!({
11590            "kind": "tasks-list",
11591            "after_task_id": null,
11592            "state": null,
11593            "limit": 10,
11594        });
11595        let decoded_tasks: HarnessReadRequestV1 = serde_json::from_value(old_shape_tasks).unwrap();
11596        match &decoded_tasks {
11597            HarnessReadRequestV1::TasksList { parent_task_id, .. } => assert!(parent_task_id.is_none()),
11598            other => panic!("expected TasksList, got {other:?}"),
11599        }
11600        decoded_tasks.validate().unwrap();
11601        assert!(HarnessTaskId::new("not-a-task-id").is_err());
11602
11603        let run_id = HarnessRunId::new(format!("hrun_{}", "b".repeat(24))).unwrap();
11604        let runs_request = HarnessReadRequestV1::RunsList {
11605            task_id: None,
11606            after_run_id: None,
11607            lifecycle: None,
11608            parent_run_id: Some(run_id.clone()),
11609            limit: 10,
11610        };
11611        runs_request.validate().unwrap();
11612        let encoded_runs = serde_json::to_string(&runs_request).unwrap();
11613        assert_eq!(
11614            serde_json::from_str::<HarnessReadRequestV1>(&encoded_runs).unwrap(),
11615            runs_request,
11616        );
11617
11618        let old_shape_runs = serde_json::json!({
11619            "kind": "runs-list",
11620            "task_id": null,
11621            "after_run_id": null,
11622            "lifecycle": null,
11623            "limit": 10,
11624        });
11625        let decoded_runs: HarnessReadRequestV1 = serde_json::from_value(old_shape_runs).unwrap();
11626        match &decoded_runs {
11627            HarnessReadRequestV1::RunsList { parent_run_id, .. } => assert!(parent_run_id.is_none()),
11628            other => panic!("expected RunsList, got {other:?}"),
11629        }
11630        decoded_runs.validate().unwrap();
11631        assert!(HarnessRunId::new("not-a-run-id").is_err());
11632    }
11633
11634    #[test]
11635    fn operator_agent_event_validate_rejects_malformed_chunks_or_sessions() {
11636        let session = session_address(1, 1);
11637
11638        let mut bad_session = session.clone();
11639        bad_session.node_id = String::new();
11640        let bad_session_event = HarnessOperatorAgentEventV1::AgentChunk {
11641            sequence: 0,
11642            session: bad_session,
11643            chunk: sample_agent_stream_chunk(0),
11644            published_at_ms: 1_000,
11645        };
11646        assert!(bad_session_event.validate().is_err());
11647
11648        let oversized_text_chunk = HarnessAgentStreamChunkV1 {
11649            source_sequence: 0,
11650            kind: HarnessAgentStreamChunkKindV1::Text {
11651                text: "x".repeat(HARNESS_AGENT_STREAM_TEXT_MAX_BYTES + 1),
11652                is_delta: false,
11653            },
11654        };
11655        let bad_chunk_event = HarnessOperatorAgentEventV1::AgentChunk {
11656            sequence: 0,
11657            session: session.clone(),
11658            chunk: oversized_text_chunk,
11659            published_at_ms: 1_000,
11660        };
11661        assert!(matches!(
11662            bad_chunk_event.validate(),
11663            Err(HarnessOperatorApiError::InvalidAgentStream),
11664        ));
11665
11666        let empty_correlation_prompt = HarnessAgentStreamChunkV1 {
11667            source_sequence: 0,
11668            kind: HarnessAgentStreamChunkKindV1::InteractionPrompt {
11669                correlation_id: String::new(),
11670                interaction_kind: HarnessProviderInteractionKindV1::Question,
11671                tool_name: "ask".to_owned(),
11672                title: None,
11673                prompt: "What next?".to_owned(),
11674                options: Vec::new(),
11675            },
11676        };
11677        let bad_prompt_event = HarnessOperatorAgentEventV1::AgentChunk {
11678            sequence: 0,
11679            session: session.clone(),
11680            chunk: empty_correlation_prompt,
11681            published_at_ms: 1_000,
11682        };
11683        assert!(matches!(
11684            bad_prompt_event.validate(),
11685            Err(HarnessOperatorApiError::InvalidAgentStream),
11686        ));
11687    }
11688
11689    /// D5, Slice D: `g4a_task_create`'s wire request round-trips and bounds
11690    /// title/body at this coarse wire-level ceiling -- the authoritative
11691    /// rule (trim/control-character checks) is the engine's own
11692    /// `HarnessTaskV1::validate`, reached at apply time, not this validate().
11693    #[test]
11694    fn task_create_request_round_trips_and_bounds_title_and_body() {
11695        let request = HarnessReadRequestV1::TaskCreate {
11696            title: "Investigate the flaky retry".to_owned(),
11697            body: "See the timeline for the failing run.".to_owned(),
11698            parent_task_id: Some(HarnessTaskId::new(format!("htask_{}", "a".repeat(24))).unwrap()),
11699        };
11700        request.validate().unwrap();
11701        let encoded = serde_json::to_string(&request).unwrap();
11702        assert_eq!(serde_json::from_str::<HarnessReadRequestV1>(&encoded).unwrap(), request);
11703
11704        let no_parent = HarnessReadRequestV1::TaskCreate {
11705            title: "Child under my own task".to_owned(),
11706            body: String::new(),
11707            parent_task_id: None,
11708        };
11709        no_parent.validate().unwrap();
11710
11711        let empty_title = HarnessReadRequestV1::TaskCreate {
11712            title: String::new(),
11713            body: String::new(),
11714            parent_task_id: None,
11715        };
11716        assert!(matches!(empty_title.validate(), Err(HarnessReadApiError::InvalidTaskCreate)));
11717
11718        let oversized_title = HarnessReadRequestV1::TaskCreate {
11719            title: "x".repeat(HARNESS_TITLE_MAX_BYTES + 1),
11720            body: String::new(),
11721            parent_task_id: None,
11722        };
11723        assert!(matches!(oversized_title.validate(), Err(HarnessReadApiError::InvalidTaskCreate)));
11724
11725        let oversized_body = HarnessReadRequestV1::TaskCreate {
11726            title: "ok".to_owned(),
11727            body: "x".repeat(HARNESS_BODY_MAX_BYTES + 1),
11728            parent_task_id: None,
11729        };
11730        assert!(matches!(oversized_body.validate(), Err(HarnessReadApiError::InvalidTaskCreate)));
11731    }
11732
11733    /// D5, Slice D: `g4a_task_move`'s wire request round-trips.
11734    #[test]
11735    fn task_move_request_round_trips() {
11736        let request = HarnessReadRequestV1::TaskMove {
11737            task_id: HarnessTaskId::new(format!("htask_{}", "b".repeat(24))).unwrap(),
11738            expected_revision: HarnessRevision::new(3).unwrap(),
11739            to: HarnessTaskStateV1::Ready,
11740        };
11741        request.validate().unwrap();
11742        let encoded = serde_json::to_string(&request).unwrap();
11743        assert_eq!(serde_json::from_str::<HarnessReadRequestV1>(&encoded).unwrap(), request);
11744    }
11745
11746    /// S10: `g4a_run_finish`'s wire request round-trips, `summary` defaults
11747    /// to `None` for a caller that omits it, and an oversized summary is a
11748    /// named validation failure (`HARNESS_BODY_MAX_BYTES`, the same bound
11749    /// task bodies enforce).
11750    #[test]
11751    fn run_finish_request_round_trips_and_bounds_summary() {
11752        let request = HarnessReadRequestV1::RunFinish {
11753            outcome: HarnessRunFinishOutcomeV1::Done,
11754            summary: Some("handed off cleanly".to_owned()),
11755        };
11756        request.validate().unwrap();
11757        let encoded = serde_json::to_string(&request).unwrap();
11758        assert_eq!(serde_json::from_str::<HarnessReadRequestV1>(&encoded).unwrap(), request);
11759
11760        let without_summary: HarnessReadRequestV1 = serde_json::from_value(serde_json::json!({
11761            "kind": "run-finish",
11762            "outcome": "failed",
11763        })).unwrap();
11764        assert_eq!(
11765            without_summary,
11766            HarnessReadRequestV1::RunFinish { outcome: HarnessRunFinishOutcomeV1::Failed, summary: None },
11767        );
11768        without_summary.validate().unwrap();
11769
11770        let oversized_summary = HarnessReadRequestV1::RunFinish {
11771            outcome: HarnessRunFinishOutcomeV1::Done,
11772            summary: Some("x".repeat(HARNESS_BODY_MAX_BYTES + 1)),
11773        };
11774        assert!(matches!(
11775            oversized_summary.validate(),
11776            Err(HarnessReadApiError::InvalidText("run finish summary")),
11777        ));
11778    }
11779
11780    /// S10: `HarnessRunFinishResultV1` round-trips through the read response
11781    /// wire (`HarnessReadResponseV1::RunFinish`).
11782    #[test]
11783    fn run_finish_result_round_trips_through_the_response_wire() {
11784        let finished = HarnessReadResponseV1::RunFinish(HarnessRunFinishResultV1::Finished {
11785            run_id: HarnessRunId::new(format!("hrun_{}", "d".repeat(24))).unwrap(),
11786            task_id: HarnessTaskId::new(format!("htask_{}", "d".repeat(24))).unwrap(),
11787            result: HarnessRunFinishOutcomeV1::Done,
11788        });
11789        finished.validate().unwrap();
11790        let encoded = serde_json::to_string(&finished).unwrap();
11791        assert_eq!(serde_json::from_str::<HarnessReadResponseV1>(&encoded).unwrap(), finished);
11792
11793        let already_finished = HarnessReadResponseV1::RunFinish(HarnessRunFinishResultV1::AlreadyFinished {
11794            run_id: HarnessRunId::new(format!("hrun_{}", "d".repeat(24))).unwrap(),
11795            lifecycle: HarnessRunLifecycleV1::Failed,
11796        });
11797        already_finished.validate().unwrap();
11798    }
11799
11800    /// D5, Slice D: every `HarnessTaskCreateResultV1`/`HarnessTaskMoveResultV1`
11801    /// variant round-trips through the read response wire
11802    /// (`HarnessReadResponseV1::TaskCreate`/`TaskMove`).
11803    #[test]
11804    fn task_create_and_move_results_round_trip_every_variant() {
11805        let created = HarnessReadResponseV1::TaskCreate(HarnessTaskCreateResultV1::Created {
11806            task_id: HarnessTaskId::new(format!("htask_{}", "c".repeat(24))).unwrap(),
11807            revision: HarnessRevision::new(1).unwrap(),
11808        });
11809        created.validate().unwrap();
11810        let encoded = serde_json::to_string(&created).unwrap();
11811        assert_eq!(serde_json::from_str::<HarnessReadResponseV1>(&encoded).unwrap(), created);
11812
11813        let parent_outside = HarnessReadResponseV1::TaskCreate(
11814            HarnessTaskCreateResultV1::ParentOutsideOwnSubtree {
11815                parent_task_id: HarnessTaskId::new(format!("htask_{}", "d".repeat(24))).unwrap(),
11816                own_task_id: HarnessTaskId::new(format!("htask_{}", "e".repeat(24))).unwrap(),
11817            },
11818        );
11819        parent_outside.validate().unwrap();
11820
11821        let parent_terminal = HarnessReadResponseV1::TaskCreate(HarnessTaskCreateResultV1::ParentTerminal {
11822            parent_task_id: HarnessTaskId::new(format!("htask_{}", "d".repeat(24))).unwrap(),
11823            state: HarnessTaskStateV1::Done,
11824        });
11825        parent_terminal.validate().unwrap();
11826
11827        let title_invalid = HarnessReadResponseV1::TaskCreate(HarnessTaskCreateResultV1::TitleInvalid {
11828            why: "task title is empty, unbounded, padded, or contains control characters".to_owned(),
11829        });
11830        title_invalid.validate().unwrap();
11831
11832        let body_invalid = HarnessReadResponseV1::TaskCreate(HarnessTaskCreateResultV1::BodyInvalid {
11833            why: "task body is unbounded".to_owned(),
11834        });
11835        body_invalid.validate().unwrap();
11836
11837        let dependencies_invalid = HarnessReadResponseV1::TaskCreate(
11838            HarnessTaskCreateResultV1::DependenciesInvalid { why: "dependencies must not self-link".to_owned() },
11839        );
11840        dependencies_invalid.validate().unwrap();
11841
11842        let moved = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::Moved {
11843            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11844            revision: HarnessRevision::new(2).unwrap(),
11845            from: HarnessTaskStateV1::Backlog,
11846            to: HarnessTaskStateV1::Ready,
11847        });
11848        moved.validate().unwrap();
11849        let encoded = serde_json::to_string(&moved).unwrap();
11850        assert_eq!(serde_json::from_str::<HarnessReadResponseV1>(&encoded).unwrap(), moved);
11851
11852        let task_is_own = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::TaskIsOwn {
11853            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11854        });
11855        task_is_own.validate().unwrap();
11856
11857        let outside_subtree = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::TaskOutsideOwnSubtree {
11858            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11859            own_task_id: HarnessTaskId::new(format!("htask_{}", "0".repeat(24))).unwrap(),
11860        });
11861        outside_subtree.validate().unwrap();
11862
11863        let illegal_transition = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::IllegalTransition {
11864            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11865            from: HarnessTaskStateV1::Done,
11866            to: HarnessTaskStateV1::Ready,
11867        });
11868        illegal_transition.validate().unwrap();
11869
11870        let revision_conflict = HarnessReadResponseV1::TaskMove(HarnessTaskMoveResultV1::RevisionConflict {
11871            task_id: HarnessTaskId::new(format!("htask_{}", "f".repeat(24))).unwrap(),
11872            expected: HarnessRevision::new(2).unwrap(),
11873            current: HarnessRevision::new(3).unwrap(),
11874        });
11875        revision_conflict.validate().unwrap();
11876    }
11877
11878    fn base_session_context() -> SessionContextV1 {
11879        SessionContextV1 {
11880            grant_id: SessionGrantId::new(format!("hgrant_{}", "a".repeat(24))).unwrap(),
11881            grant_revision: HarnessRevision::new(1).unwrap(),
11882            actor_run: CallerRunV1 {
11883                run_id: HarnessRunId::new(format!("hrun_{}", "a".repeat(24))).unwrap(),
11884                task_id: None,
11885                parent_run_id: None,
11886                lifecycle: HarnessRunLifecycleV1::Running,
11887                references_redacted: false,
11888            },
11889            task: None,
11890            sibling_runs: Vec::new(),
11891            read_permissions: HarnessReadPermissionsV1 {
11892                tasks: HarnessEntityReadScopeV1::None,
11893                runs: HarnessEntityReadScopeV1::None,
11894                operations: HarnessEntityReadScopeV1::None,
11895            },
11896            monitoring_visibility: HarnessMonitoringVisibilityV1::None,
11897            child_task_count: 0,
11898            child_task_subtree_depth: 0,
11899            task_create: false,
11900            task_mutate: false,
11901            allowed_tool_ids: vec!["g4a_context_get".to_owned(), "g4a_run_finish".to_owned()],
11902            history_message_count: None,
11903            completed_turn_count: None,
11904            total_tokens: None,
11905        }
11906    }
11907
11908    /// D5, Slice D: `expected_allowed_tool_ids` derives `g4a_task_create`/
11909    /// `g4a_task_move` from `task_permissions.create`/`.mutate` -- create-only
11910    /// advertises `g4a_task_create` alone, and a grant cannot claim a tool it
11911    /// was not actually granted (`InvalidAllowedTools`).
11912    #[test]
11913    fn expected_allowed_tool_ids_derives_task_tools_from_task_permissions() {
11914        let base = base_session_context();
11915        base.validate().unwrap();
11916
11917        let create_only = SessionContextV1 {
11918            task_create: true,
11919            allowed_tool_ids: vec![
11920                "g4a_context_get".to_owned(),
11921                "g4a_run_finish".to_owned(),
11922                "g4a_task_create".to_owned(),
11923            ],
11924            ..base.clone()
11925        };
11926        create_only.validate().unwrap();
11927
11928        let both = SessionContextV1 {
11929            task_create: true,
11930            task_mutate: true,
11931            allowed_tool_ids: vec![
11932                "g4a_context_get".to_owned(),
11933                "g4a_run_finish".to_owned(),
11934                "g4a_task_create".to_owned(),
11935                "g4a_task_move".to_owned(),
11936            ],
11937            ..base.clone()
11938        };
11939        both.validate().unwrap();
11940
11941        let create_only_claims_move = SessionContextV1 {
11942            task_create: true,
11943            allowed_tool_ids: vec![
11944                "g4a_context_get".to_owned(),
11945                "g4a_task_create".to_owned(),
11946                "g4a_task_move".to_owned(),
11947            ],
11948            ..base
11949        };
11950        assert!(matches!(
11951            create_only_claims_move.validate(),
11952            Err(HarnessReadApiError::InvalidAllowedTools),
11953        ));
11954    }
11955
11956    /// The removed `maximum_child_count`/`maximum_child_depth` wire fields
11957    /// stated a cap that was checked for range but never compared against an
11958    /// actual child count anywhere -- this proves their replacements
11959    /// (`child_task_count`/`child_task_subtree_depth`) carry whatever the
11960    /// engine observed, with no ceiling of their own: a value far past the
11961    /// old `HARNESS_CHILD_COUNT_MAX`/`HARNESS_CHILD_DEPTH_MAX` bounds still
11962    /// validates and round-trips over the wire under the new field names.
11963    #[test]
11964    fn child_task_observations_carry_unbounded_values() {
11965        let base = base_session_context();
11966        let many_children = SessionContextV1 {
11967            child_task_count: u64::from(HARNESS_CHILD_COUNT_MAX) * 1_000,
11968            child_task_subtree_depth: u64::from(HARNESS_CHILD_DEPTH_MAX) * 1_000,
11969            ..base
11970        };
11971        many_children.validate().unwrap();
11972        let encoded = serde_json::to_value(&many_children).unwrap();
11973        assert_eq!(
11974            encoded.get("child_task_count").and_then(serde_json::Value::as_u64),
11975            Some(u64::from(HARNESS_CHILD_COUNT_MAX) * 1_000),
11976        );
11977        assert_eq!(
11978            encoded.get("child_task_subtree_depth").and_then(serde_json::Value::as_u64),
11979            Some(u64::from(HARNESS_CHILD_DEPTH_MAX) * 1_000),
11980        );
11981        assert!(encoded.get("maximum_child_count").is_none());
11982        assert!(encoded.get("maximum_child_depth").is_none());
11983        let decoded: SessionContextV1 = serde_json::from_value(encoded).unwrap();
11984        assert_eq!(decoded, many_children);
11985    }
11986}