Skip to main content

hatchery_c2_protocol/
lib.rs

1//! Stable, serde-only inventory and control contract for Gate4Agent C2.
2
3pub use gate4agent_node_protocol::{
4    AdapterContractRevision, AdapterFamily, AdapterId, AgentId, ArchitectureId, CapabilityId,
5    ClientCompatibilityOffer, HostDescriptor, HostDirectoryEntry, HostDirectoryListing,
6    ManagedWorktreeCleanupFailure, ManagedWorktreeGitScope, ManagedWorktreeLeaseId,
7    ManagedWorktreeLeaseSnapshot,
8    ManagedWorktreeLeaseState, ManagedWorktreeRetention, ManagedWorktreeSpawnReceipt,
9    ManagedWorktreeSpawnRequest, ManagedWorktreeSpawnRequestV2, ManagedWorktreeProfileSummary,
10    WorktreeProfileInventory,
11    WorktreeServiceMode,
12    provider_id_is_legacy, NodeCursor, NodeEvent, NodeFailure, NodeId, NodeIncarnationId, NodeRequest,
13    NodeResponse, OpaqueHostPath, OperatingSystemId, PathEncoding, PathSemantics, PathStyle,
14    ProviderAdapterContractSupport, ProviderContractRevision, ProviderContractSupport,
15    ProviderRuntimeContractId, ProviderRuntimeMode, ProviderRuntimeStatus,
16    ProviderRuntimeStatuses, ProviderRuntimeVersion,
17    LaunchInventory, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
18    ResolvedSpawnReceipt, SpawnProfileSummary,
19    ContextPackBytesRead, ContextPackLineageReceipt, ResolvedContextPackReceipt, SpawnContextDigest,
20    ResolvedSpawnSpec, SpawnBundleDigest, SpawnBundleId, SpawnBundleRevision,
21    SpawnContextId, SpawnDeadlineMs, SpawnEnvironmentProfileId,
22    SpawnEnvironmentProfileRevision, SpawnNetworkAllowlistId, SpawnBrowserProfileId,
23    SpawnFieldProvenance, SpawnIdempotencyKey, SpawnOverride,
24    SpawnOverrides, SpawnProfileDefaults, SpawnProfileId, SpawnProfileRevision, SpawnPrompt,
25    SpawnPromptMetadata, SpawnRequiredCapabilities, SpawnResolutionProvenance, SpawnSpec,
26    SpawnTarget,
27    WorktreeProfileId, WorktreeProfileRevision,
28    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
29    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
30    NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
31    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
32    NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
33    NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
34    NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
35    NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
36    NODE_NATIVE_SESSION_INDEX_CAPABILITY,
37    NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
38    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
39    NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
40    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
41    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
42    NODE_PROVIDER_ID_OPEN_CAPABILITY, NODE_SESSION_TASK_CORRELATION_CAPABILITY,
43    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
44    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
45    NODE_AGENT_STREAM_EVENTS_CAPABILITY,
46    NODE_ACP_CONTROL_CAPABILITY,
47    NODE_WORKTREE_SELECTION_CAPABILITY,
48    GitDiff, GitHistoryPage, RepositoryPath, WorkspaceFileContent, WorkspaceFileRead,
49    HistoryCandidateSummary, NativeSessionCatalogEntry, NativeSessionCatalogPage,
50    NativeSessionCatalogRoute, NativeSessionCatalogScope, NativeSessionCatalogSummary,
51    NativeSessionCatalogWindow, NativeSessionExternalGroup, NativeSessionExternalGroupKind,
52    NativeSessionPreview, NativeSessionSelection, SessionRecordPreview,
53    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
54    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, SessionAgentProgress,
55    SessionTaskBindingV1, SessionTaskTargetV1, TaskId,
56    DeliveryBlobChunkHexV1, DeliveryBlobDigestV1, DeliveryBlobReceiptV1,
57    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryManifestDigestV2,
58    DeliveryStageId,
59    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
60    HarnessMcpActivationDigest, HarnessMcpCallId, HarnessMcpContentTypeV1,
61    HarnessMcpOpaquePayloadV1, HarnessMcpRejectReasonV1,
62    HarnessMcpReplyChunkHexV1, HarnessMcpReservationId,
63    ResolvedHarnessMcpProxyReceiptV1,
64    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
65    ProtocolNegotiationError, ProtocolRange,
66};
67pub use gate4agent_build_stamp::BUILD_STAMP;
68mod control_detail;
69pub use control_detail::control_telemetry_detail;
70
71mod multiplex;
72pub use multiplex::*;
73pub mod inner;
74#[cfg(feature = "nested-crypto")]
75pub mod nested;
76use gate4agent_node_protocol::{
77    AgentStreamChunkV1, ManagedSessionRecord, ManagedSessionState, SessionHistorySummaryV1,
78    NodeSnapshot, SessionAddress, SessionMode, SessionRecordId, WorkspaceId,
79};
80use gate4agent_types::{
81    AgentInstanceId, OperationId, PreparedInputKind, ProviderActivity, PtyScreenState,
82    SessionGeneration, SessionStatus, TerminalFrame, TerminalSize, TransportKind,
83};
84use serde::de::{SeqAccess, Visitor};
85use serde::{Deserialize, Deserializer, Serialize};
86use std::collections::BTreeMap;
87use std::fmt;
88
89pub const C2_API_VERSION: u16 = 2;
90pub const DEFAULT_C2_API_LISTEN: &str = "127.0.0.1:18320";
91pub const C2_COMPATIBILITY_METADATA_CAPABILITY: &str = "compatibility.metadata";
92pub const C2_OPAQUE_UNIX_PATH_CAPABILITY: &str =
93    gate4agent_node_protocol::NODE_OPAQUE_UNIX_PATH_CAPABILITY;
94pub const C2_REPOSITORY_PATH_CAPABILITY: &str =
95    gate4agent_node_protocol::NODE_REPOSITORY_PATH_CAPABILITY;
96pub const C2_WORKSPACE_FILE_READ_CAPABILITY: &str =
97    gate4agent_node_protocol::NODE_WORKSPACE_FILE_READ_CAPABILITY;
98pub const C2_WORKSPACE_FILE_WRITE_CAPABILITY: &str =
99    gate4agent_node_protocol::NODE_WORKSPACE_FILE_WRITE_CAPABILITY;
100pub const C2_WORKSPACE_ENTRY_CREATE_CAPABILITY: &str =
101    NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY;
102pub const C2_GIT_READ_CAPABILITY: &str = gate4agent_node_protocol::NODE_GIT_READ_CAPABILITY;
103pub const C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY: &str =
104    gate4agent_node_protocol::NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY;
105pub const C2_PROVIDER_RUNTIME_STATUS_CAPABILITY: &str =
106    gate4agent_node_protocol::NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY;
107pub const C2_PROVIDER_ID_OPEN_CAPABILITY: &str = NODE_PROVIDER_ID_OPEN_CAPABILITY;
108pub const C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY: &str =
109    NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY;
110pub const C2_SPAWN_PROFILE_REVISION_CAPABILITY: &str =
111    NODE_SPAWN_PROFILE_REVISION_CAPABILITY;
112pub const C2_TERMINAL_FRAME_EVENTS_CAPABILITY: &str = NODE_TERMINAL_FRAME_EVENTS_CAPABILITY;
113/// The outbound agent-content stream -- `C2NodeEvent::AgentStream` -- gated
114/// the same way `C2_TERMINAL_FRAME_EVENTS_CAPABILITY` gates
115/// `C2NodeEvent::TerminalFrame`: a peer that never negotiated it must never
116/// receive it.
117pub const C2_AGENT_STREAM_EVENTS_CAPABILITY: &str = NODE_AGENT_STREAM_EVENTS_CAPABILITY;
118/// The four ACP control verbs -- `ResolveInteraction`, `SetSessionMode`,
119/// `SetSessionConfigOption`, `SetSessionModel` -- gated the same way every
120/// other capability-scoped path is: a peer that never negotiated it must
121/// never have its requests admitted.
122pub const C2_ACP_CONTROL_CAPABILITY: &str = NODE_ACP_CONTROL_CAPABILITY;
123pub const C2_WORKTREE_SELECTION_CAPABILITY: &str = NODE_WORKTREE_SELECTION_CAPABILITY;
124pub const C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY: &str =
125    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY;
126pub const C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY: &str =
127    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY;
128pub const C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY: &str =
129    NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY;
130pub const C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY: &str =
131    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY;
132pub const C2_HISTORY_CONTEXT_PACK_CAPABILITY: &str = NODE_HISTORY_CONTEXT_PACK_CAPABILITY;
133pub const C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY: &str =
134    NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY;
135pub const C2_NATIVE_SESSION_CATALOG_CAPABILITY: &str = NODE_NATIVE_SESSION_CATALOG_CAPABILITY;
136pub const C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY: &str =
137    NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY;
138pub const C2_NATIVE_SESSION_PREVIEW_CAPABILITY: &str = NODE_NATIVE_SESSION_PREVIEW_CAPABILITY;
139pub const C2_NATIVE_SESSION_INDEX_CAPABILITY: &str = NODE_NATIVE_SESSION_INDEX_CAPABILITY;
140pub const C2_HOST_DIRECTORY_BROWSE_CAPABILITY: &str = CAPABILITY_HOST_DIRECTORY_BROWSE_V1;
141pub const C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY: &str =
142    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY;
143pub const C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY: &str =
144    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY;
145pub const C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY: &str =
146    gate4agent_node_protocol::NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY;
147pub const C2_SESSION_TASK_CORRELATION_CAPABILITY: &str =
148    NODE_SESSION_TASK_CORRELATION_CAPABILITY;
149/// The sanitized control-event detail -- `C2ControlEvent::detail` -- gated the
150/// same way `C2_AGENT_STREAM_EVENTS_CAPABILITY` gates the agent stream: a peer
151/// that never negotiated it must never receive it. A C2-level capability, not a
152/// node one: the node always publishes its full control events and the C2
153/// decides, per connection, what of them crosses.
154pub const C2_CONTROL_DETAIL_CAPABILITY: &str = "control-detail-v1";
155pub const C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY: &str =
156    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY;
157pub const C2_HARNESS_MCP_READ_PROXY_CAPABILITY: &str =
158    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY;
159pub const C2_AUTH_NONCE_BYTES: usize = 32;
160pub const C2_AUTH_PROOF_BYTES: usize = 32;
161pub const MAX_C2_AUTH_COMPATIBILITY_CAPABILITIES: usize = 64;
162pub const MAX_C2_BOUND_AUTH_TRANSCRIPT_BYTES: usize = 16 * 1024;
163pub const MAX_C2_CLIENT_FRAME_BYTES: usize = 256 * 1024;
164pub const MAX_C2_SERVER_FRAME_BYTES: usize = 8 * 1024 * 1024;
165pub const MAX_C2_AUTH_FRAME_BYTES: usize = 8 * 1024;
166pub const MAX_C2_HELLO_FRAME_BYTES: usize = MAX_C2_SERVER_FRAME_BYTES;
167pub const MAX_C2_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
168pub const MAX_C2_NODES: usize = 64;
169pub const MAX_C2_ENDPOINT_BYTES: usize = 1024;
170pub const MAX_C2_WORKSPACES_PER_NODE: usize = 32;
171pub const MAX_C2_SESSIONS_PER_NODE: usize = 128;
172pub const MAX_C2_MANAGED_SESSIONS_PER_NODE: usize = 128;
173pub const MAX_C2_MANAGED_WORKTREES_PER_NODE: usize =
174    gate4agent_node_protocol::MAX_MANAGED_WORKTREE_LEASES;
175pub const MAX_C2_GAPS_PER_NODE: usize = 64;
176pub const MAX_C2_ROOT_BYTES: usize = 1024;
177pub const MAX_C2_SESSION_DISPLAY_NAME_BYTES: usize =
178    gate4agent_node_protocol::MAX_SESSION_DISPLAY_NAME_BYTES;
179
180#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
181#[serde(transparent)]
182pub struct C2RequestId(pub u64);
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
185pub struct NodeRoute {
186    pub node_id: NodeId,
187    pub expected_incarnation_id: NodeIncarnationId,
188}
189
190#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
191pub struct RoutedNodeRequest {
192    pub route: NodeRoute,
193    pub request: NodeRequest,
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
197pub struct RoutedNodeResponse {
198    pub node_id: NodeId,
199    pub incarnation_id: NodeIncarnationId,
200    pub response: Result<C2NodeResponse, C2NodeFailure>,
201}
202
203#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
204pub struct RoutedNodeEvent {
205    pub node_id: NodeId,
206    pub cursor: NodeCursor,
207    pub event: C2NodeEvent,
208}
209
210#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
211pub struct C2NodeFailure {
212    pub code: gate4agent_node_protocol::NodeFailureCode,
213    pub message: String,
214}
215
216impl From<&NodeFailure> for C2NodeFailure {
217    fn from(failure: &NodeFailure) -> Self {
218        use gate4agent_node_protocol::NodeFailureCode;
219        let message = match failure.code {
220            NodeFailureCode::InvalidRequest => "invalid request",
221            NodeFailureCode::UnsupportedCapability => "required capability unavailable",
222            NodeFailureCode::SpawnProfileRevisionMismatch => {
223                "spawn profile revision mismatch"
224            }
225            NodeFailureCode::HarnessMcpUnavailable => "harness MCP proxy unavailable",
226            NodeFailureCode::ReservationNotFound => "harness MCP reservation unavailable",
227            NodeFailureCode::ReservationConflict => "harness MCP reservation conflict",
228            NodeFailureCode::ReservationExpired => "harness MCP reservation expired",
229            NodeFailureCode::BindingMismatch => "harness MCP binding mismatch",
230            NodeFailureCode::NotActivated => "harness MCP reservation not activated",
231            NodeFailureCode::CallNotFound => "harness MCP call unavailable",
232            NodeFailureCode::ChunkOutOfOrder => "harness MCP reply chunk out of order",
233            NodeFailureCode::ResponseTooLarge => "harness MCP response too large",
234            NodeFailureCode::DeliveryManifestInvalid => "delivery manifest invalid",
235            NodeFailureCode::UnknownDeliveryStage => "delivery stage unavailable",
236            NodeFailureCode::DeliveryStageConflict => "delivery stage conflict",
237            NodeFailureCode::DeliveryBlobUnexpected => "delivery blob unexpected",
238            NodeFailureCode::DeliveryChunkOutOfOrder => "delivery chunk out of order",
239            NodeFailureCode::DeliveryBlobDigestMismatch => "delivery blob digest mismatch",
240            NodeFailureCode::DeliveryBundleDigestMismatch => "delivery bundle digest mismatch",
241            NodeFailureCode::DeliveryStageIncomplete => "delivery stage incomplete",
242            NodeFailureCode::DeliveryStageStorageFailed => "delivery stage storage failed",
243            NodeFailureCode::Unauthorized => "authentication rejected",
244            NodeFailureCode::ObserverReadOnly => "operator access required",
245            NodeFailureCode::ControllerBusy => "controller busy",
246            NodeFailureCode::ControllerRequired => "controller required",
247            NodeFailureCode::UnknownWorkspace => "workspace unavailable",
248            NodeFailureCode::HostDirectoryInvalid => "host directory invalid",
249            NodeFailureCode::HostDirectoryReadFailed => "host directory read failed",
250            NodeFailureCode::HostDirectoryReadTimedOut => "host directory read timed out",
251            NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
252                "standalone workspace recovery required"
253            }
254            NodeFailureCode::InvalidRepositoryPath => "repository path invalid",
255            NodeFailureCode::RepositoryFileNotFound => "repository file unavailable",
256            NodeFailureCode::RepositoryFileNotRegular => "repository path is not a regular file",
257            NodeFailureCode::RepositoryPathUnsafe => "repository path is unsafe",
258            NodeFailureCode::RepositoryFileReadTimedOut => "repository file read timed out",
259            NodeFailureCode::RepositoryFileReadFailed => "repository file read failed",
260            NodeFailureCode::RepositoryFileWriteTimedOut => "repository file write timed out",
261            NodeFailureCode::RepositoryFileWriteFailed => "repository file write failed",
262            NodeFailureCode::RepositoryFileRevisionConflict => "repository file changed since it was opened",
263            NodeFailureCode::RepositoryEntryAlreadyExists => "repository entry already exists",
264            NodeFailureCode::RepositoryParentNotFound => "repository parent directory unavailable",
265            NodeFailureCode::RepositoryParentNotDirectory => "repository parent path is not a directory",
266            NodeFailureCode::RepositoryEntryCreateTimedOut => "repository entry creation timed out",
267            NodeFailureCode::RepositoryEntryCreateFailed => "repository entry creation failed",
268            NodeFailureCode::GitReadTimedOut => "git read timed out",
269            NodeFailureCode::GitReadFailed => "git read failed",
270            NodeFailureCode::InvalidWorkspaceRoot => "workspace root invalid",
271            NodeFailureCode::DuplicateWorkspaceId => "workspace ID already registered",
272            NodeFailureCode::DuplicateWorkspaceRoot => "workspace root already registered",
273            NodeFailureCode::WorkspaceBusy => "workspace busy",
274            NodeFailureCode::LastWorkspace => "last workspace protected",
275            NodeFailureCode::NotGitRepository => "workspace is not a git repository",
276            NodeFailureCode::WorktreeConflict => "worktree conflict",
277            NodeFailureCode::WorktreeProtected => "worktree protected",
278            NodeFailureCode::WorktreeDirty => "worktree dirty",
279            NodeFailureCode::WorktreeLocked => "worktree locked",
280            NodeFailureCode::UnknownManagedWorktreeLease => "managed worktree unavailable",
281            NodeFailureCode::ManagedWorktreeBusy => "managed worktree busy",
282            NodeFailureCode::ManagedWorktreeOwnershipConflict => {
283                "managed worktree ownership conflict"
284            }
285            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
286                "managed worktree profile revision mismatch"
287            }
288            NodeFailureCode::ManagedWorktreeRecoveryRequired => {
289                "managed worktree recovery required"
290            }
291            NodeFailureCode::SpawnTargetMismatch => "spawn target mismatch",
292            NodeFailureCode::UnknownSpawnProfile => "spawn profile unavailable",
293            NodeFailureCode::UnknownBundle => "session bundle unavailable",
294            NodeFailureCode::UnknownEnvironmentProfile => {
295                "environment profile unavailable"
296            }
297            NodeFailureCode::UnknownNetworkAllowlist => {
298                "network allowlist unavailable"
299            }
300            NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
301                "network allowlist mapping unsupported for this provider"
302            }
303            NodeFailureCode::BrowserStationProbeUnavailable => {
304                "dig2browser station probe unavailable on this platform"
305            }
306            NodeFailureCode::BrowserStationUnreachable => {
307                "dig2browser station unreachable"
308            }
309            NodeFailureCode::BrowserStationProfileBusy => {
310                "dig2browser station profile busy"
311            }
312            NodeFailureCode::BundleBindingMismatch => {
313                "session bundle binding mismatch"
314            }
315            NodeFailureCode::EnvironmentProfileBindingMismatch => {
316                "environment profile binding mismatch"
317            }
318            NodeFailureCode::BundleMaterializationFailed => {
319                "session bundle materialization failed"
320            }
321            NodeFailureCode::SpawnIdempotencyConflict => "spawn idempotency conflict",
322            NodeFailureCode::SpawnIdempotencyCapacity => "spawn idempotency capacity exhausted",
323            NodeFailureCode::SpawnDeadlineExceeded => "spawn deadline exceeded",
324            NodeFailureCode::UnsupportedSpawnCapability => "spawn capability unavailable",
325            NodeFailureCode::UnsupportedTransport => "provider does not support the requested transport",
326            NodeFailureCode::TurnInFlight => "session already has a turn in flight",
327            NodeFailureCode::UnknownSession => "session unavailable",
328            NodeFailureCode::UnknownSessionRecord => "managed session unavailable",
329            NodeFailureCode::SessionRecordNotResumable => "managed session cannot resume",
330            NodeFailureCode::SessionRecordBusy => "managed session busy",
331            NodeFailureCode::SessionRecordConflict => "managed session conflict",
332            NodeFailureCode::SessionWorkspaceMismatch => "session workspace mismatch",
333            NodeFailureCode::WorkspaceRegistrationRequired => {
334                "workspace registration required"
335            }
336            NodeFailureCode::StaleNativeSessionCatalog => "native session catalog is stale",
337            NodeFailureCode::UnknownContextPack => "context pack unavailable",
338            NodeFailureCode::ContextPackBusy => "context pack busy",
339            NodeFailureCode::ContextPackMaterializationFailed => {
340                "context pack materialization failed"
341            }
342            NodeFailureCode::StaleGeneration => "stale session generation",
343            NodeFailureCode::BackendBusy => "node backend busy",
344            NodeFailureCode::BackendDisconnected => "node backend disconnected",
345            NodeFailureCode::BackendOperationFailed => "node backend operation failed",
346            NodeFailureCode::ShuttingDown => "node shutting down",
347        };
348        Self { code: failure.code, message: message.to_owned() }
349    }
350}
351
352impl C2NodeFailure {
353    pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
354        use gate4agent_node_protocol::NodeFailureCode;
355        matches!(self.code,
356            NodeFailureCode::HarnessMcpUnavailable
357                | NodeFailureCode::ReservationNotFound
358                | NodeFailureCode::ReservationConflict
359                | NodeFailureCode::ReservationExpired
360                | NodeFailureCode::BindingMismatch
361                | NodeFailureCode::NotActivated
362                | NodeFailureCode::CallNotFound
363                | NodeFailureCode::ChunkOutOfOrder
364                | NodeFailureCode::ResponseTooLarge)
365    }
366
367    pub fn requires_host_directory_browse_capability(&self) -> bool {
368        use gate4agent_node_protocol::NodeFailureCode;
369        matches!(
370            self.code,
371            NodeFailureCode::HostDirectoryInvalid
372                | NodeFailureCode::HostDirectoryReadFailed
373                | NodeFailureCode::HostDirectoryReadTimedOut
374        )
375    }
376
377    pub fn requires_history_context_pack_capability(&self) -> bool {
378        use gate4agent_node_protocol::NodeFailureCode;
379        matches!(
380            self.code,
381            NodeFailureCode::UnknownContextPack
382                | NodeFailureCode::ContextPackBusy
383                | NodeFailureCode::ContextPackMaterializationFailed
384        )
385    }
386
387    pub fn requires_native_session_catalog_paging_capability(&self) -> bool {
388        self.code == gate4agent_node_protocol::NodeFailureCode::StaleNativeSessionCatalog
389    }
390}
391
392#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
393pub struct C2ManagedSessionRecord {
394    pub record_id: SessionRecordId,
395    pub display_name: String,
396    pub provider: AgentId,
397    pub mode: SessionMode,
398    pub state: ManagedSessionState,
399    pub workspace_id: WorkspaceId,
400    pub active_session: Option<SessionAddress>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub bundle: Option<ResolvedBundleReceipt>,
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub context_id: Option<SpawnContextId>,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub context: Option<ResolvedContextPackReceipt>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub exported_context: Option<ResolvedContextPackReceipt>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub task_binding: Option<SessionTaskBindingV1>,
413    pub provider_identity_present: bool,
414    pub created_at_unix_ms: u64,
415    pub updated_at_unix_ms: u64,
416}
417
418impl C2ManagedSessionRecord {
419    pub fn context_binding_is_valid(&self) -> bool {
420        match (self.context_id.as_ref(), self.context.as_ref()) {
421            (None, None) => true,
422            (Some(context_id), Some(context)) => {
423                &context.id == context_id && context.is_valid()
424            }
425            (None, Some(_)) | (Some(_), None) => false,
426        }
427    }
428
429    pub fn requires_history_context_pack_capability(&self) -> bool {
430        self.context_id.is_some() || self.context.is_some()
431    }
432
433    pub fn exported_context_is_valid(&self) -> bool {
434        self.exported_context.as_ref().map_or(true, |pack| {
435            pack.is_valid() && pack.lineage.source_provider == self.provider
436        })
437    }
438
439    pub fn requires_session_task_correlation_capability(&self) -> bool {
440        self.task_binding.is_some()
441    }
442
443    pub fn task_binding_is_valid(&self) -> bool {
444        self.task_binding.as_ref().map_or(true, |binding| {
445            binding.revision > 0
446                && binding.changed_at_unix_ms >= self.created_at_unix_ms
447                && binding.changed_at_unix_ms <= self.updated_at_unix_ms
448        })
449    }
450}
451
452impl<'de> Deserialize<'de> for C2ManagedSessionRecord {
453    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
454    where
455        D: Deserializer<'de>,
456    {
457        #[derive(Deserialize)]
458        struct WireRecord {
459            record_id: SessionRecordId,
460            display_name: String,
461            provider: AgentId,
462            mode: SessionMode,
463            state: ManagedSessionState,
464            workspace_id: WorkspaceId,
465            active_session: Option<SessionAddress>,
466            #[serde(default)]
467            environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
468            #[serde(default)]
469            bundle: Option<ResolvedBundleReceipt>,
470            #[serde(default)]
471            context_id: Option<SpawnContextId>,
472            #[serde(default)]
473            context: Option<ResolvedContextPackReceipt>,
474            #[serde(default)]
475            exported_context: Option<ResolvedContextPackReceipt>,
476            #[serde(default)]
477            task_binding: Option<SessionTaskBindingV1>,
478            provider_identity_present: bool,
479            created_at_unix_ms: u64,
480            updated_at_unix_ms: u64,
481        }
482
483        let wire = WireRecord::deserialize(deserializer)?;
484        let record = Self {
485            record_id: wire.record_id,
486            display_name: wire.display_name,
487            provider: wire.provider,
488            mode: wire.mode,
489            state: wire.state,
490            workspace_id: wire.workspace_id,
491            active_session: wire.active_session,
492            environment_profile: wire.environment_profile,
493            bundle: wire.bundle,
494            context_id: wire.context_id,
495            context: wire.context,
496            exported_context: wire.exported_context,
497            task_binding: wire.task_binding,
498            provider_identity_present: wire.provider_identity_present,
499            created_at_unix_ms: wire.created_at_unix_ms,
500            updated_at_unix_ms: wire.updated_at_unix_ms,
501        };
502        if !record.context_binding_is_valid() {
503            return Err(serde::de::Error::custom(
504                "C2 managed session context id and materialization receipt are not correlated",
505            ));
506        }
507        if !record.exported_context_is_valid() {
508            return Err(serde::de::Error::custom(
509                "C2 managed session exported context receipt is invalid or from a different provider",
510            ));
511        }
512        if !record.task_binding_is_valid() {
513            return Err(serde::de::Error::custom(
514                "C2 managed session task binding revision or timestamp is invalid",
515            ));
516        }
517        Ok(record)
518    }
519}
520
521impl From<&ManagedSessionRecord> for C2ManagedSessionRecord {
522    fn from(record: &ManagedSessionRecord) -> Self {
523        Self {
524            record_id: record.record_id.clone(),
525            display_name: record.display_name.clone(),
526            provider: record.provider.clone(),
527            mode: record.mode,
528            state: record.state,
529            workspace_id: record.workspace_id.clone(),
530            active_session: record.active_session.clone(),
531            environment_profile: record.environment_profile.clone(),
532            bundle: record.bundle.clone(),
533            context_id: record.context_id.clone(),
534            context: record.context.clone(),
535            exported_context: record.exported_context.clone(),
536            task_binding: record.task_binding.clone().filter(|binding| {
537                binding.revision > 0
538                    && binding.changed_at_unix_ms >= record.created_at_unix_ms
539                    && binding.changed_at_unix_ms <= record.updated_at_unix_ms
540            }),
541            provider_identity_present: record.provider_session.is_some(),
542            created_at_unix_ms: record.created_at_unix_ms,
543            updated_at_unix_ms: record.updated_at_unix_ms,
544        }
545    }
546
547}
548
549#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
550#[serde(tag = "kind", rename_all = "kebab-case")]
551pub enum C2SessionStatus {
552    Registered,
553    Starting,
554    Running,
555    Stopping,
556    Exited { exit_code: Option<i32> },
557    Failed,
558}
559
560impl From<&SessionStatus> for C2SessionStatus {
561    fn from(status: &SessionStatus) -> Self {
562        match status {
563            SessionStatus::Registered => Self::Registered,
564            SessionStatus::Starting => Self::Starting,
565            SessionStatus::Running => Self::Running,
566            SessionStatus::Stopping => Self::Stopping,
567            SessionStatus::Exited { exit_code } => Self::Exited { exit_code: *exit_code },
568            SessionStatus::Failed { .. } => Self::Failed,
569        }
570    }
571}
572
573#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
574pub struct C2SessionSnapshot {
575    pub instance_id: AgentInstanceId,
576    pub agent_id: AgentId,
577    pub transport: TransportKind,
578    pub generation: SessionGeneration,
579    pub status: C2SessionStatus,
580    pub pending_operation: Option<OperationId>,
581    pub pending_input: Option<PreparedInputKind>,
582    pub process_id: Option<u32>,
583    pub terminal_size: Option<TerminalSize>,
584    pub terminal_frame: Option<TerminalFrame>,
585    pub provider_activity: ProviderActivity,
586    pub provider_interaction_pending: bool,
587    pub provider_identity_present: bool,
588    /// The session's CURRENT screen classification, projected from
589    /// `gate4agent_types::SessionSnapshot::screen_state` the same way
590    /// `provider_activity`/`provider_interaction_pending` are: as its own
591    /// field rather than something a reader reconstructs from
592    /// `terminal_frame`, which only carries the classification stamped at
593    /// one past frame, not the session's current one. `#[serde(default)]`
594    /// so a peer that predates this field decodes it as `Unknown`.
595    #[serde(default)]
596    pub screen_state: PtyScreenState,
597}
598
599impl From<&gate4agent_types::SessionSnapshot> for C2SessionSnapshot {
600    fn from(session: &gate4agent_types::SessionSnapshot) -> Self {
601        Self {
602            instance_id: session.instance_id,
603            agent_id: session.agent_id.clone(),
604            transport: session.transport,
605            generation: session.generation,
606            status: C2SessionStatus::from(&session.status),
607            pending_operation: session.pending_operation,
608            pending_input: session.pending_input,
609            process_id: session.process_id,
610            terminal_size: session.terminal_size,
611            terminal_frame: session.terminal_frame.clone(),
612            provider_activity: session.provider.activity,
613            provider_interaction_pending: !session.provider.interactions.is_empty(),
614            provider_identity_present: session.provider.session.is_some(),
615            screen_state: session.screen_state.clone(),
616        }
617    }
618}
619
620#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
621pub struct C2WorkspaceSnapshot {
622    pub workspace_id: WorkspaceId,
623    pub canonical_root: OpaqueHostPath,
624    pub sessions: Vec<C2SessionSnapshot>,
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub worktree_service_mode: Option<WorktreeServiceMode>,
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub managed_worktree_profiles: Option<WorktreeProfileInventory>,
629}
630
631impl From<&gate4agent_node_protocol::WorkspaceSnapshot> for C2WorkspaceSnapshot {
632    fn from(workspace: &gate4agent_node_protocol::WorkspaceSnapshot) -> Self {
633        Self {
634            workspace_id: workspace.workspace_id.clone(),
635            canonical_root: workspace.canonical_root.clone(),
636            sessions: workspace.sessions.iter().map(C2SessionSnapshot::from).collect(),
637            worktree_service_mode: workspace.worktree_service_mode,
638            managed_worktree_profiles: workspace.managed_worktree_profiles.clone(),
639        }
640    }
641}
642
643#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
644pub struct C2NodeSnapshot {
645    pub node_id: NodeId,
646    pub enabled_providers: Vec<AgentId>,
647    #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
648    pub provider_runtime_statuses: ProviderRuntimeStatuses,
649    pub workspaces: Vec<C2WorkspaceSnapshot>,
650    pub session_records: Vec<C2ManagedSessionRecord>,
651    #[serde(
652        default,
653        skip_serializing_if = "Vec::is_empty",
654        deserialize_with = "deserialize_c2_agent_progress_entries"
655    )]
656    pub agent_progress: Vec<SessionAgentProgress>,
657    #[serde(
658        default,
659        skip_serializing_if = "Vec::is_empty",
660        deserialize_with = "deserialize_c2_managed_worktree_leases"
661    )]
662    pub managed_worktrees: Vec<ManagedWorktreeLeaseSnapshot>,
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub launch_inventory: Option<LaunchInventory>,
665}
666
667impl C2NodeSnapshot {
668    pub fn requires_child_environment_profile_capability(&self) -> bool {
669        self.session_records
670            .iter()
671            .any(|record| record.environment_profile.is_some())
672            || self.launch_inventory.as_ref().is_some_and(|inventory| {
673                inventory.spawn_profiles.as_ref().is_some_and(|profiles| {
674                    profiles
675                        .iter()
676                        .any(|profile| profile.environment_profile.is_some())
677                })
678            })
679    }
680
681    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
682        self.session_records
683            .iter()
684            .any(|record| record.bundle.is_some())
685    }
686
687    pub fn requires_history_context_pack_capability(&self) -> bool {
688        self.session_records
689            .iter()
690            .any(C2ManagedSessionRecord::requires_history_context_pack_capability)
691    }
692
693    pub fn requires_session_task_correlation_capability(&self) -> bool {
694        self.session_records
695            .iter()
696            .any(C2ManagedSessionRecord::requires_session_task_correlation_capability)
697    }
698}
699
700fn deserialize_c2_managed_worktree_leases<'de, D>(
701    deserializer: D,
702) -> Result<Vec<ManagedWorktreeLeaseSnapshot>, D::Error>
703where
704    D: Deserializer<'de>,
705{
706    struct ManagedWorktreesVisitor;
707
708    impl<'de> Visitor<'de> for ManagedWorktreesVisitor {
709        type Value = Vec<ManagedWorktreeLeaseSnapshot>;
710
711        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
712            write!(
713                formatter,
714                "at most {MAX_C2_MANAGED_WORKTREES_PER_NODE} managed worktrees",
715            )
716        }
717
718        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
719        where
720            A: SeqAccess<'de>,
721        {
722            let mut leases = Vec::with_capacity(
723                sequence
724                    .size_hint()
725                    .unwrap_or(0)
726                    .min(MAX_C2_MANAGED_WORKTREES_PER_NODE),
727            );
728            while let Some(lease) = sequence.next_element::<ManagedWorktreeLeaseSnapshot>()? {
729                if leases.len() == MAX_C2_MANAGED_WORKTREES_PER_NODE {
730                    return Err(serde::de::Error::invalid_length(leases.len() + 1, &self));
731                }
732                if leases.iter().any(|existing: &ManagedWorktreeLeaseSnapshot| {
733                    existing.lease_id == lease.lease_id
734                        || existing.workspace_id == lease.workspace_id
735                }) {
736                    return Err(serde::de::Error::custom(
737                        "C2 managed worktree snapshot contains duplicate identity",
738                    ));
739                }
740                leases.push(lease);
741            }
742            Ok(leases)
743        }
744    }
745
746    deserializer.deserialize_seq(ManagedWorktreesVisitor)
747}
748
749fn deserialize_c2_agent_progress_entries<'de, D>(
750    deserializer: D,
751) -> Result<Vec<SessionAgentProgress>, D::Error>
752where
753    D: Deserializer<'de>,
754{
755    struct AgentProgressEntriesVisitor;
756
757    impl<'de> Visitor<'de> for AgentProgressEntriesVisitor {
758        type Value = Vec<SessionAgentProgress>;
759
760        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
761            write!(
762                formatter,
763                "at most {} bounded agent progress entries",
764                gate4agent_node_protocol::MAX_AGENT_PROGRESS_ENTRIES,
765            )
766        }
767
768        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
769        where
770            A: SeqAccess<'de>,
771        {
772            let mut entries = Vec::with_capacity(
773                sequence
774                    .size_hint()
775                    .unwrap_or(0)
776                    .min(gate4agent_node_protocol::MAX_AGENT_PROGRESS_ENTRIES),
777            );
778            while let Some(entry) = sequence.next_element::<SessionAgentProgress>()? {
779                if entries.len() == gate4agent_node_protocol::MAX_AGENT_PROGRESS_ENTRIES {
780                    return Err(serde::de::Error::invalid_length(entries.len() + 1, &self));
781                }
782                if entries.iter().any(|existing: &SessionAgentProgress| {
783                    existing.address == entry.address
784                }) {
785                    continue;
786                }
787                entries.push(entry);
788            }
789            Ok(entries)
790        }
791    }
792
793    deserializer.deserialize_seq(AgentProgressEntriesVisitor)
794}
795
796impl From<&NodeSnapshot> for C2NodeSnapshot {
797    fn from(snapshot: &NodeSnapshot) -> Self {
798        Self {
799            node_id: snapshot.node_id.clone(),
800            enabled_providers: snapshot.enabled_providers.clone(),
801            provider_runtime_statuses: snapshot.provider_runtime_statuses.clone(),
802            workspaces: snapshot.workspaces.iter().map(C2WorkspaceSnapshot::from).collect(),
803            session_records: snapshot.session_records.iter()
804                .map(C2ManagedSessionRecord::from)
805                .collect(),
806            agent_progress: snapshot.agent_progress.clone(),
807            managed_worktrees: snapshot.managed_worktrees.clone(),
808            launch_inventory: snapshot.launch_inventory.clone(),
809        }
810    }
811}
812
813#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
814pub struct C2NodeEventEnvelope {
815    pub sequence: u64,
816    pub event: C2NodeEvent,
817}
818
819impl From<&gate4agent_node_protocol::NodeEventEnvelope> for C2NodeEventEnvelope {
820    fn from(envelope: &gate4agent_node_protocol::NodeEventEnvelope) -> Self {
821        Self { sequence: envelope.sequence, event: C2NodeEvent::from(&envelope.event) }
822    }
823}
824
825impl C2NodeEventEnvelope {
826    /// Like `From`, but a `NodeEvent::Control` keeps its sanitized telemetry
827    /// detail. A C2 relay uses this and strips the detail again for every
828    /// connection that did not negotiate `C2_CONTROL_DETAIL_CAPABILITY`.
829    pub fn from_node_envelope_with_control_detail(
830        envelope: &gate4agent_node_protocol::NodeEventEnvelope,
831    ) -> Self {
832        Self {
833            sequence: envelope.sequence,
834            event: C2NodeEvent::from_node_event_with_control_detail(&envelope.event),
835        }
836    }
837}
838
839#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
840pub struct C2ControlEvent {
841    pub sequence: u64,
842    pub command_id: Option<gate4agent_types::CommandId>,
843    pub instance_id: AgentInstanceId,
844    pub generation: SessionGeneration,
845    pub event: C2ControlEventKind,
846    /// The sanitized facts behind the tag, for a client that derives its own
847    /// telemetry (tool calls, token usage, plans, lifecycle) and negotiated
848    /// `C2_CONTROL_DETAIL_CAPABILITY`. `None` on every default projection; see
849    /// [`control_telemetry_detail`] for exactly what it keeps and clears.
850    #[serde(default, skip_serializing_if = "Option::is_none")]
851    pub detail: Option<gate4agent_types::ControlEvent>,
852}
853
854#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
855#[serde(rename_all = "kebab-case")]
856pub enum C2ProviderEventKind {
857    SessionStarted,
858    SessionIdentityObserved,
859    TurnStarted,
860    WorkingObserved,
861    Text,
862    Thinking,
863    ToolStarted,
864    ToolCompleted,
865    TurnCompleted,
866    ContextWindowUsage,
867    TurnInterrupted,
868    SessionEnded,
869    Error,
870    Ready,
871    InteractionRequested,
872    InteractionResolved,
873    SubagentStarted,
874    SubagentStopped,
875    RateLimited,
876    HostRequestObserved,
877    UnrecognizedNotification,
878}
879
880impl From<&gate4agent_types::ProviderEvent> for C2ProviderEventKind {
881    fn from(event: &gate4agent_types::ProviderEvent) -> Self {
882        use gate4agent_types::ProviderEvent;
883        match event {
884            ProviderEvent::SessionStarted { .. } => Self::SessionStarted,
885            ProviderEvent::SessionIdentityObserved { .. } => Self::SessionIdentityObserved,
886            ProviderEvent::TurnStarted { .. } => Self::TurnStarted,
887            ProviderEvent::WorkingObserved => Self::WorkingObserved,
888            ProviderEvent::Text { .. } => Self::Text,
889            ProviderEvent::Thinking { .. } => Self::Thinking,
890            ProviderEvent::ToolStarted { .. } => Self::ToolStarted,
891            ProviderEvent::ToolCompleted { .. } => Self::ToolCompleted,
892            ProviderEvent::TurnCompleted { .. } => Self::TurnCompleted,
893            ProviderEvent::ContextWindowUsage { .. } => Self::ContextWindowUsage,
894            ProviderEvent::TurnInterrupted => Self::TurnInterrupted,
895            ProviderEvent::SessionEnded { .. } => Self::SessionEnded,
896            ProviderEvent::Error { .. } => Self::Error,
897            ProviderEvent::Ready => Self::Ready,
898            ProviderEvent::InteractionRequested { .. } => Self::InteractionRequested,
899            ProviderEvent::InteractionResolved { .. } => Self::InteractionResolved,
900            ProviderEvent::SubagentStarted { .. } => Self::SubagentStarted,
901            ProviderEvent::SubagentStopped { .. } => Self::SubagentStopped,
902            ProviderEvent::RateLimited { .. } => Self::RateLimited,
903            ProviderEvent::HostRequestObserved { .. } => Self::HostRequestObserved,
904            ProviderEvent::UnrecognizedNotification { .. } => Self::UnrecognizedNotification,
905            // ACP session/update coverage beyond text/tool/turn streaming
906            // (`plan`, `available_commands_update`, `current_mode_update`,
907            // `session_info_update`, `usage_update`, `config_option_
908            // update`, `user_message_chunk`). `C2ProviderEventKind` is a
909            // wire enum without a `#[serde(other)]` fallback; minting new
910            // variants for it is a deliberate wire-contract decision for
911            // whoever owns this protocol, not a side effect of parsing
912            // more of ACP's own wire -- until that decision is made, these
913            // fold into the same bucket as `UnrecognizedNotification`.
914            ProviderEvent::UserMessage { .. }
915            | ProviderEvent::Plan { .. }
916            | ProviderEvent::AvailableCommandsUpdated { .. }
917            | ProviderEvent::ModeChanged { .. }
918            | ProviderEvent::SessionInfoUpdated { .. }
919            | ProviderEvent::UsageUpdated { .. }
920            | ProviderEvent::ConfigOptionsUpdated { .. } => Self::UnrecognizedNotification,
921        }
922    }
923}
924
925#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
926#[serde(tag = "kind", rename_all = "kebab-case")]
927pub enum C2ControlEventKind {
928    CommandRejected,
929    Registered,
930    StartRequested,
931    Running,
932    StopRequested,
933    InputRequested,
934    InputCompleted,
935    InputFailed,
936    ResizeRequested,
937    Resized,
938    ResizeFailed,
939    ForegroundRefreshRequested,
940    ForegroundObserved,
941    ForegroundFailed,
942    CapabilityProbeRequested,
943    CapabilitiesProbed,
944    CapabilityProbeFailed,
945    HistoryRequested,
946    HistoryDiscovered,
947    HistoryLoaded,
948    HistoryFailed,
949    ResumeRequested,
950    ResumeAuthorized,
951    Resumed,
952    ResumeDenied,
953    ResumeFailed,
954    TerminalStale,
955    ProviderEvent { event: C2ProviderEventKind },
956    ProviderGap,
957    InteractionRequested,
958    InteractionResolutionRequested,
959    InteractionResolutionFailed,
960    InteractionResolved,
961    // The nine session-control tags below are the same shape as the
962    // interaction trio above: this projection carries WHICH kind of thing
963    // happened, never the mode/option/model id it happened to. An id is
964    // the session's own vocabulary and has no business on a relay wire.
965    SessionModeSetRequested,
966    SessionModeSet,
967    SessionModeSetFailed,
968    SessionConfigOptionSetRequested,
969    SessionConfigOptionSet,
970    SessionConfigOptionSetFailed,
971    SessionModelSetRequested,
972    SessionModelSet,
973    SessionModelSetFailed,
974    Exited { exit_code: Option<i32>, forced: bool },
975    Failed,
976    Removed,
977    ObservationIgnored,
978}
979
980impl From<&gate4agent_types::ControlEvent> for C2ControlEvent {
981    fn from(event: &gate4agent_types::ControlEvent) -> Self {
982        use gate4agent_types::ControlEventKind;
983        let projected = match &event.event {
984            ControlEventKind::CommandRejected { .. } => C2ControlEventKind::CommandRejected,
985            ControlEventKind::Registered => C2ControlEventKind::Registered,
986            ControlEventKind::StartRequested { .. } => C2ControlEventKind::StartRequested,
987            ControlEventKind::Running { .. } => C2ControlEventKind::Running,
988            ControlEventKind::StopRequested { .. } => C2ControlEventKind::StopRequested,
989            ControlEventKind::InputRequested { .. } => C2ControlEventKind::InputRequested,
990            ControlEventKind::InputCompleted { .. } => C2ControlEventKind::InputCompleted,
991            ControlEventKind::InputFailed { .. } => C2ControlEventKind::InputFailed,
992            ControlEventKind::ResizeRequested { .. } => C2ControlEventKind::ResizeRequested,
993            ControlEventKind::Resized { .. } => C2ControlEventKind::Resized,
994            ControlEventKind::ResizeFailed { .. } => C2ControlEventKind::ResizeFailed,
995            ControlEventKind::ForegroundRefreshRequested { .. } => C2ControlEventKind::ForegroundRefreshRequested,
996            ControlEventKind::ForegroundObserved { .. } => C2ControlEventKind::ForegroundObserved,
997            ControlEventKind::ForegroundFailed { .. } => C2ControlEventKind::ForegroundFailed,
998            ControlEventKind::CapabilityProbeRequested { .. } => C2ControlEventKind::CapabilityProbeRequested,
999            ControlEventKind::CapabilitiesProbed { .. } => C2ControlEventKind::CapabilitiesProbed,
1000            ControlEventKind::CapabilityProbeFailed { .. } => C2ControlEventKind::CapabilityProbeFailed,
1001            ControlEventKind::HistoryRequested { .. } => C2ControlEventKind::HistoryRequested,
1002            ControlEventKind::HistoryDiscovered { .. } => C2ControlEventKind::HistoryDiscovered,
1003            ControlEventKind::HistoryLoaded { .. } => C2ControlEventKind::HistoryLoaded,
1004            ControlEventKind::HistoryFailed { .. } => C2ControlEventKind::HistoryFailed,
1005            ControlEventKind::ResumeRequested { .. } => C2ControlEventKind::ResumeRequested,
1006            ControlEventKind::ResumeAuthorized { .. } => C2ControlEventKind::ResumeAuthorized,
1007            ControlEventKind::Resumed { .. } => C2ControlEventKind::Resumed,
1008            ControlEventKind::ResumeDenied { .. } => C2ControlEventKind::ResumeDenied,
1009            ControlEventKind::ResumeFailed { .. } => C2ControlEventKind::ResumeFailed,
1010            ControlEventKind::TerminalStale { .. } => C2ControlEventKind::TerminalStale,
1011            ControlEventKind::ProviderEvent { event, .. } => C2ControlEventKind::ProviderEvent {
1012                event: C2ProviderEventKind::from(event),
1013            },
1014            ControlEventKind::ProviderGap { .. } => C2ControlEventKind::ProviderGap,
1015            ControlEventKind::InteractionRequested { .. } => C2ControlEventKind::InteractionRequested,
1016            ControlEventKind::InteractionResolutionRequested { .. } => C2ControlEventKind::InteractionResolutionRequested,
1017            ControlEventKind::InteractionResolutionFailed { .. } => C2ControlEventKind::InteractionResolutionFailed,
1018            ControlEventKind::InteractionResolved { .. } => C2ControlEventKind::InteractionResolved,
1019            ControlEventKind::SessionModeSetRequested { .. } => C2ControlEventKind::SessionModeSetRequested,
1020            ControlEventKind::SessionModeSet { .. } => C2ControlEventKind::SessionModeSet,
1021            ControlEventKind::SessionModeSetFailed { .. } => C2ControlEventKind::SessionModeSetFailed,
1022            ControlEventKind::SessionConfigOptionSetRequested { .. } => C2ControlEventKind::SessionConfigOptionSetRequested,
1023            ControlEventKind::SessionConfigOptionSet { .. } => C2ControlEventKind::SessionConfigOptionSet,
1024            ControlEventKind::SessionConfigOptionSetFailed { .. } => C2ControlEventKind::SessionConfigOptionSetFailed,
1025            ControlEventKind::SessionModelSetRequested { .. } => C2ControlEventKind::SessionModelSetRequested,
1026            ControlEventKind::SessionModelSet { .. } => C2ControlEventKind::SessionModelSet,
1027            ControlEventKind::SessionModelSetFailed { .. } => C2ControlEventKind::SessionModelSetFailed,
1028            ControlEventKind::Exited { exit_code, forced } => C2ControlEventKind::Exited {
1029                exit_code: *exit_code,
1030                forced: *forced,
1031            },
1032            ControlEventKind::Failed { .. } => C2ControlEventKind::Failed,
1033            ControlEventKind::Removed => C2ControlEventKind::Removed,
1034            ControlEventKind::ObservationIgnored { .. } => C2ControlEventKind::ObservationIgnored,
1035        };
1036        Self {
1037            sequence: event.sequence,
1038            command_id: event.command_id,
1039            instance_id: event.instance_id,
1040            generation: event.generation,
1041            event: projected,
1042            detail: None,
1043        }
1044    }
1045}
1046
1047#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1048#[serde(tag = "kind", rename_all = "kebab-case")]
1049pub enum C2NodeEvent {
1050    HarnessMcpReadCall {
1051        reservation_id: HarnessMcpReservationId,
1052        activation_digest: HarnessMcpActivationDigest,
1053        record_id: SessionRecordId,
1054        session: SessionAddress,
1055        call_id: HarnessMcpCallId,
1056        request: HarnessMcpOpaquePayloadV1,
1057        deadline_unix_ms: u64,
1058    },
1059    Control {
1060        address: SessionAddress,
1061        event: C2ControlEvent,
1062    },
1063    SessionRecordHistorySummarized {
1064        record_id: SessionRecordId,
1065        summary: SessionHistorySummaryV1,
1066    },
1067    TerminalFrame {
1068        address: SessionAddress,
1069        frame: TerminalFrame,
1070    },
1071    AgentStream {
1072        address: SessionAddress,
1073        chunk: AgentStreamChunkV1,
1074    },
1075    ControllerChanged {
1076        controller: Option<gate4agent_node_protocol::ControllerState>,
1077    },
1078    WorkspaceAdded {
1079        workspace: C2WorkspaceSnapshot,
1080    },
1081    WorkspaceRemoved { workspace_id: WorkspaceId },
1082    SessionRecordUpserted { record: C2ManagedSessionRecord },
1083    SessionRecordRemoved { record_id: SessionRecordId },
1084    ManagedWorktreeUpserted { lease: ManagedWorktreeLeaseSnapshot },
1085    ManagedWorktreeRemoved { lease_id: ManagedWorktreeLeaseId },
1086    ResyncRequired { oldest_available_sequence: u64 },
1087}
1088
1089impl From<&NodeEvent> for C2NodeEvent {
1090    fn from(event: &NodeEvent) -> Self {
1091        match event {
1092            NodeEvent::HarnessMcpReadCall {
1093                reservation_id,
1094                activation_digest,
1095                record_id,
1096                session,
1097                call_id,
1098                request,
1099                deadline_unix_ms,
1100            } => Self::HarnessMcpReadCall {
1101                reservation_id: reservation_id.clone(),
1102                activation_digest: activation_digest.clone(),
1103                record_id: record_id.clone(),
1104                session: session.clone(),
1105                call_id: call_id.clone(),
1106                request: request.clone(),
1107                deadline_unix_ms: *deadline_unix_ms,
1108            },
1109            NodeEvent::Control { address, event } => Self::Control {
1110                address: address.clone(),
1111                event: C2ControlEvent::from(event),
1112            },
1113            NodeEvent::SessionRecordHistorySummarized { record_id, summary } => {
1114                Self::SessionRecordHistorySummarized {
1115                    record_id: record_id.clone(),
1116                    summary: *summary,
1117                }
1118            }
1119            NodeEvent::TerminalFrame { address, frame } => Self::TerminalFrame {
1120                address: address.clone(),
1121                frame: frame.clone(),
1122            },
1123            NodeEvent::AgentStream { address, chunk } => Self::AgentStream {
1124                address: address.clone(),
1125                chunk: chunk.clone(),
1126            },
1127            NodeEvent::ControllerChanged { controller } => Self::ControllerChanged {
1128                controller: controller.clone(),
1129            },
1130            NodeEvent::WorkspaceAdded { workspace } => Self::WorkspaceAdded {
1131                workspace: C2WorkspaceSnapshot::from(workspace),
1132            },
1133            NodeEvent::WorkspaceRemoved { workspace_id } => Self::WorkspaceRemoved {
1134                workspace_id: workspace_id.clone(),
1135            },
1136            NodeEvent::SessionRecordUpserted { record } => Self::SessionRecordUpserted {
1137                record: C2ManagedSessionRecord::from(record),
1138            },
1139            NodeEvent::SessionRecordRemoved { record_id } => Self::SessionRecordRemoved {
1140                record_id: record_id.clone(),
1141            },
1142            NodeEvent::ManagedWorktreeUpserted { lease } => Self::ManagedWorktreeUpserted {
1143                lease: lease.clone(),
1144            },
1145            NodeEvent::ManagedWorktreeRemoved { lease_id } => Self::ManagedWorktreeRemoved {
1146                lease_id: lease_id.clone(),
1147            },
1148            NodeEvent::ResyncRequired { oldest_available_sequence } => Self::ResyncRequired {
1149                oldest_available_sequence: *oldest_available_sequence,
1150            },
1151        }
1152    }
1153}
1154
1155impl C2NodeEvent {
1156    /// Like `From`, but a `NodeEvent::Control` keeps its sanitized telemetry
1157    /// detail; see [`control_telemetry_detail`].
1158    pub fn from_node_event_with_control_detail(event: &NodeEvent) -> Self {
1159        match event {
1160            NodeEvent::Control { address, event } => Self::Control {
1161                address: address.clone(),
1162                event: C2ControlEvent {
1163                    detail: control_telemetry_detail(event),
1164                    ..C2ControlEvent::from(event)
1165                },
1166            },
1167            other => Self::from(other),
1168        }
1169    }
1170
1171    pub fn requires_control_detail_capability(&self) -> bool {
1172        matches!(self, Self::Control { event, .. } if event.detail.is_some())
1173    }
1174
1175    /// Drops the control detail, leaving the coarse tag projection.
1176    pub fn strip_control_detail(&mut self) {
1177        if let Self::Control { event, .. } = self {
1178            event.detail = None;
1179        }
1180    }
1181
1182    pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
1183        matches!(self, Self::HarnessMcpReadCall { .. })
1184    }
1185
1186    pub fn harness_mcp_contract_is_valid_at(&self, now_unix_ms: u64) -> bool {
1187        match self {
1188            Self::HarnessMcpReadCall {
1189                reservation_id,
1190                activation_digest,
1191                record_id,
1192                session,
1193                call_id,
1194                request,
1195                deadline_unix_ms,
1196            } => NodeEvent::HarnessMcpReadCall {
1197                reservation_id: reservation_id.clone(),
1198                activation_digest: activation_digest.clone(),
1199                record_id: record_id.clone(),
1200                session: session.clone(),
1201                call_id: call_id.clone(),
1202                request: request.clone(),
1203                deadline_unix_ms: *deadline_unix_ms,
1204            }.harness_mcp_contract_is_valid_at(now_unix_ms),
1205            _ => true,
1206        }
1207    }
1208
1209    pub fn requires_child_environment_profile_capability(&self) -> bool {
1210        matches!(self, Self::SessionRecordUpserted { record }
1211            if record.environment_profile.is_some())
1212    }
1213
1214    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
1215        matches!(self, Self::SessionRecordUpserted { record }
1216            if record.bundle.is_some())
1217    }
1218
1219    pub fn requires_history_context_pack_capability(&self) -> bool {
1220        matches!(self, Self::SessionRecordUpserted { record }
1221            if record.requires_history_context_pack_capability())
1222    }
1223
1224    pub fn requires_session_task_correlation_capability(&self) -> bool {
1225        matches!(self, Self::SessionRecordUpserted { record }
1226            if record.requires_session_task_correlation_capability())
1227    }
1228}
1229
1230#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1231pub struct C2GitWorktreeSnapshot {
1232    pub path: OpaqueHostPath,
1233    pub head: String,
1234    pub branch: Option<String>,
1235    pub is_bare: bool,
1236    pub is_main: bool,
1237    pub locked: bool,
1238    pub prunable: bool,
1239    pub workspace_id: Option<WorkspaceId>,
1240}
1241
1242impl From<&gate4agent_node_protocol::GitWorktreeSnapshot> for C2GitWorktreeSnapshot {
1243    fn from(worktree: &gate4agent_node_protocol::GitWorktreeSnapshot) -> Self {
1244        Self {
1245            path: worktree.path.clone(),
1246            head: worktree.head.clone(),
1247            branch: worktree.branch.clone(),
1248            is_bare: worktree.is_bare,
1249            is_main: worktree.is_main,
1250            locked: worktree.locked,
1251            prunable: worktree.prunable,
1252            workspace_id: worktree.workspace_id.clone(),
1253        }
1254    }
1255}
1256
1257#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1258pub struct C2GitSnapshot {
1259    pub is_repository: bool,
1260    pub branch: Option<String>,
1261    pub status: Vec<gate4agent_node_protocol::GitStatusEntry>,
1262    pub recent_commits: Vec<gate4agent_node_protocol::GitCommitSummary>,
1263    pub worktrees: Vec<C2GitWorktreeSnapshot>,
1264    #[serde(default, skip_serializing_if = "Option::is_none")]
1265    pub managed_worktree: Option<ManagedWorktreeGitScope>,
1266    pub truncated: bool,
1267    pub diagnostic_present: bool,
1268}
1269
1270impl From<&gate4agent_node_protocol::GitSnapshot> for C2GitSnapshot {
1271    fn from(git: &gate4agent_node_protocol::GitSnapshot) -> Self {
1272        Self {
1273            is_repository: git.is_repository,
1274            branch: git.branch.clone(),
1275            status: git.status.clone(),
1276            recent_commits: git.recent_commits.clone(),
1277            worktrees: git.worktrees.iter().map(C2GitWorktreeSnapshot::from).collect(),
1278            managed_worktree: git.managed_worktree.clone(),
1279            truncated: git.truncated,
1280            diagnostic_present: git.diagnostic.is_some(),
1281        }
1282    }
1283}
1284
1285impl C2GitSnapshot {
1286    pub fn managed_worktree_is_valid_for(&self, workspace_id: &WorkspaceId) -> bool {
1287        self.managed_worktree.as_ref().map_or(true, |scope| {
1288            self.is_repository
1289                && self.branch.as_deref() == Some(scope.branch.as_str())
1290                && &scope.source_workspace_id != workspace_id
1291                && (u32::from(scope.active_session_count)
1292                    + u32::from(scope.managed_record_count))
1293                    > 0
1294        })
1295    }
1296}
1297
1298#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1299pub struct C2WorkspaceInspection {
1300    pub workspace_id: WorkspaceId,
1301    pub entries: Vec<gate4agent_node_protocol::WorkspaceEntry>,
1302    pub tree_truncated: bool,
1303    pub git: C2GitSnapshot,
1304    /// Additive mirror of `gate4agent_node_protocol::WorkspaceInspection::
1305    /// truncation` — reuses the node-protocol type directly (plain
1306    /// counts/bools, nothing sensitive), matching how `status`/
1307    /// `recent_commits` already reuse node-protocol leaf types unchanged.
1308    #[serde(default, skip_serializing_if = "Option::is_none")]
1309    pub truncation: Option<gate4agent_node_protocol::WorkspaceInspectionTruncationV1>,
1310}
1311
1312impl<'de> Deserialize<'de> for C2WorkspaceInspection {
1313    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1314    where
1315        D: serde::Deserializer<'de>,
1316    {
1317        #[derive(Deserialize)]
1318        struct WireInspection {
1319            workspace_id: WorkspaceId,
1320            entries: Vec<gate4agent_node_protocol::WorkspaceEntry>,
1321            tree_truncated: bool,
1322            git: C2GitSnapshot,
1323            #[serde(default)]
1324            truncation: Option<gate4agent_node_protocol::WorkspaceInspectionTruncationV1>,
1325        }
1326
1327        let wire = WireInspection::deserialize(deserializer)?;
1328        if !wire.git.managed_worktree_is_valid_for(&wire.workspace_id) {
1329            return Err(serde::de::Error::custom(
1330                "managed worktree git scope is inconsistent with workspace inspection",
1331            ));
1332        }
1333        Ok(Self {
1334            workspace_id: wire.workspace_id,
1335            entries: wire.entries,
1336            tree_truncated: wire.tree_truncated,
1337            git: wire.git,
1338            truncation: wire.truncation,
1339        })
1340    }
1341}
1342
1343impl From<&gate4agent_node_protocol::WorkspaceInspection> for C2WorkspaceInspection {
1344    fn from(inspection: &gate4agent_node_protocol::WorkspaceInspection) -> Self {
1345        let mut projected = Self {
1346            workspace_id: inspection.workspace_id.clone(),
1347            entries: inspection.entries.clone(),
1348            tree_truncated: inspection.tree_truncated,
1349            git: C2GitSnapshot::from(&inspection.git),
1350            truncation: inspection.truncation,
1351        };
1352        if !projected
1353            .git
1354            .managed_worktree_is_valid_for(&projected.workspace_id)
1355        {
1356            projected.git.managed_worktree = None;
1357        }
1358        projected
1359    }
1360}
1361
1362#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1363#[serde(tag = "kind", rename_all = "kebab-case")]
1364pub enum C2NodeResponse {
1365    Snapshot {
1366        event_sequence: u64,
1367        controller: Option<gate4agent_node_protocol::ControllerState>,
1368        snapshot: C2NodeSnapshot,
1369    },
1370    Resync {
1371        event_sequence: u64,
1372        oldest_available_sequence: u64,
1373        snapshot: C2NodeSnapshot,
1374        events: Vec<C2NodeEventEnvelope>,
1375    },
1376    Armed {
1377        reservation_id: HarnessMcpReservationId,
1378        activation_digest: HarnessMcpActivationDigest,
1379        expires_at_unix_ms: u64,
1380    },
1381    Spawned {
1382        reservation_id: HarnessMcpReservationId,
1383        activation_digest: HarnessMcpActivationDigest,
1384        receipt: ResolvedSpawnReceipt,
1385    },
1386    Activated {
1387        reservation_id: HarnessMcpReservationId,
1388        activation_digest: HarnessMcpActivationDigest,
1389        record_id: SessionRecordId,
1390        session: SessionAddress,
1391    },
1392    Aborted {
1393        reservation_id: HarnessMcpReservationId,
1394        activation_digest: HarnessMcpActivationDigest,
1395    },
1396    ReplyChunkAccepted {
1397        reservation_id: HarnessMcpReservationId,
1398        activation_digest: HarnessMcpActivationDigest,
1399        record_id: SessionRecordId,
1400        session: SessionAddress,
1401        call_id: HarnessMcpCallId,
1402        next_offset: u32,
1403        completed: bool,
1404    },
1405    CallRejected {
1406        reservation_id: HarnessMcpReservationId,
1407        activation_digest: HarnessMcpActivationDigest,
1408        record_id: SessionRecordId,
1409        session: SessionAddress,
1410        call_id: HarnessMcpCallId,
1411    },
1412    DeliveryStageBegun {
1413        stage_id: DeliveryStageId,
1414        manifest_digest: DeliveryManifestDigestV2,
1415        missing_blobs: Vec<DeliveryBlobDigestV1>,
1416    },
1417    DeliveryBlobChunkAccepted {
1418        stage_id: DeliveryStageId,
1419        blob_digest: DeliveryBlobDigestV1,
1420        next_offset: u64,
1421    },
1422    DeliveryCommitted {
1423        receipt: DeliveryCommitReceiptV1,
1424    },
1425    DeliveryStageAborted {
1426        stage_id: DeliveryStageId,
1427    },
1428    WorkspaceInspected {
1429        inspection: C2WorkspaceInspection,
1430    },
1431    HostDirectoriesBrowsed {
1432        listing: HostDirectoryListing,
1433    },
1434    WorkspaceFileRead {
1435        file: WorkspaceFileRead,
1436    },
1437    WorkspaceFileWritten {
1438        file: WorkspaceFileRead,
1439    },
1440    WorkspaceFileCreated {
1441        file: WorkspaceFileRead,
1442    },
1443    WorkspaceDirectoryCreated {
1444        workspace_id: WorkspaceId,
1445        entry: gate4agent_node_protocol::WorkspaceEntry,
1446    },
1447    GitHistoryRead {
1448        workspace_id: WorkspaceId,
1449        page: GitHistoryPage,
1450    },
1451    GitDiffRead {
1452        workspace_id: WorkspaceId,
1453        diff: GitDiff,
1454    },
1455    Controller {
1456        controller: Option<gate4agent_node_protocol::ControllerState>,
1457    },
1458    SpawnAccepted { session: SessionAddress },
1459    SpawnSpecAccepted { receipt: ResolvedSpawnReceipt },
1460    ManagedWorktreeSpawnAccepted { receipt: ManagedWorktreeSpawnReceipt },
1461    ManagedWorktreeCleanup { lease: ManagedWorktreeLeaseSnapshot },
1462    SessionRecordUpdated { record: C2ManagedSessionRecord },
1463    ProviderSessionIndexed { record: C2ManagedSessionRecord },
1464    NativeSessionIndexed {
1465        selection: NativeSessionSelection,
1466        record: C2ManagedSessionRecord,
1467    },
1468    SessionRecordResumed {
1469        record: C2ManagedSessionRecord,
1470        session: SessionAddress,
1471    },
1472    SessionRecordForgotten { record_id: SessionRecordId },
1473    NativeSessionsCataloged {
1474        route: NativeSessionCatalogRoute,
1475        #[serde(deserialize_with = "deserialize_c2_native_session_catalog_entries")]
1476        entries: Vec<NativeSessionCatalogEntry>,
1477        #[serde(
1478            default,
1479            skip_serializing_if = "Option::is_none",
1480            deserialize_with = "deserialize_optional_c2_native_session_catalog_summary"
1481        )]
1482        summary: Option<NativeSessionCatalogSummary>,
1483    },
1484    NativeSessionsPaged {
1485        route: NativeSessionCatalogRoute,
1486        #[serde(deserialize_with = "deserialize_c2_native_session_catalog_page")]
1487        page: NativeSessionCatalogPage,
1488    },
1489    NativeSessionPreviewed {
1490        selection: NativeSessionSelection,
1491        #[serde(deserialize_with = "deserialize_c2_native_session_preview")]
1492        preview: NativeSessionPreview,
1493    },
1494    SessionRecordPreviewed {
1495        record_id: SessionRecordId,
1496        #[serde(deserialize_with = "deserialize_c2_session_record_preview")]
1497        preview: SessionRecordPreview,
1498    },
1499    HistoryDiscovered {
1500        session: SessionAddress,
1501        #[serde(deserialize_with = "deserialize_c2_history_candidates")]
1502        candidates: Vec<HistoryCandidateSummary>,
1503    },
1504    HistoryLoaded {
1505        session: SessionAddress,
1506        #[serde(deserialize_with = "deserialize_c2_history_session_id")]
1507        session_id: String,
1508        message_count: u64,
1509        #[serde(default, skip_serializing_if = "Option::is_none")]
1510        completed_turn_count: Option<u64>,
1511    },
1512    ContextPackExported { context: ResolvedContextPackReceipt },
1513    ContextPackForSessionRecordExported {
1514        record_id: SessionRecordId,
1515        session: SessionAddress,
1516        context: ResolvedContextPackReceipt,
1517    },
1518    ContextPackForgotten { context_id: SpawnContextId },
1519    DurableContextPackResolved { context: ResolvedContextPackReceipt },
1520    ContextPackBytesRead { pack: ContextPackBytesRead },
1521    WorkspaceRegistered {
1522        workspace: C2WorkspaceSnapshot,
1523    },
1524    StandaloneWorkspaceCreated {
1525        workspace: C2WorkspaceSnapshot,
1526    },
1527    WorkspaceUnregistered { workspace_id: WorkspaceId },
1528    WorktreeCreated {
1529        worktree: C2GitWorktreeSnapshot,
1530        workspace: C2WorkspaceSnapshot,
1531    },
1532    WorktreeRemoved {
1533        target_root: OpaqueHostPath,
1534        workspace_id: Option<WorkspaceId>,
1535    },
1536    Accepted,
1537    ShuttingDown,
1538}
1539
1540impl From<&NodeResponse> for C2NodeResponse {
1541    fn from(response: &NodeResponse) -> Self {
1542        match response {
1543            NodeResponse::Snapshot { event_sequence, controller, snapshot } => Self::Snapshot {
1544                event_sequence: *event_sequence,
1545                controller: controller.clone(),
1546                snapshot: C2NodeSnapshot::from(snapshot),
1547            },
1548            NodeResponse::Resync {
1549                event_sequence,
1550                oldest_available_sequence,
1551                snapshot,
1552                events,
1553            } => Self::Resync {
1554                event_sequence: *event_sequence,
1555                oldest_available_sequence: *oldest_available_sequence,
1556                snapshot: C2NodeSnapshot::from(snapshot),
1557                events: events.iter().map(C2NodeEventEnvelope::from).collect(),
1558            },
1559            NodeResponse::Armed {
1560                reservation_id,
1561                activation_digest,
1562                expires_at_unix_ms,
1563            } => Self::Armed {
1564                reservation_id: reservation_id.clone(),
1565                activation_digest: activation_digest.clone(),
1566                expires_at_unix_ms: *expires_at_unix_ms,
1567            },
1568            NodeResponse::Spawned { reservation_id, activation_digest, receipt } => {
1569                Self::Spawned {
1570                    reservation_id: reservation_id.clone(),
1571                    activation_digest: activation_digest.clone(),
1572                    receipt: receipt.clone(),
1573                }
1574            }
1575            NodeResponse::Activated {
1576                reservation_id,
1577                activation_digest,
1578                record_id,
1579                session,
1580            } => Self::Activated {
1581                reservation_id: reservation_id.clone(),
1582                activation_digest: activation_digest.clone(),
1583                record_id: record_id.clone(),
1584                session: session.clone(),
1585            },
1586            NodeResponse::Aborted { reservation_id, activation_digest } => Self::Aborted {
1587                reservation_id: reservation_id.clone(),
1588                activation_digest: activation_digest.clone(),
1589            },
1590            NodeResponse::ReplyChunkAccepted {
1591                reservation_id,
1592                activation_digest,
1593                record_id,
1594                session,
1595                call_id,
1596                next_offset,
1597                completed,
1598            } => Self::ReplyChunkAccepted {
1599                reservation_id: reservation_id.clone(),
1600                activation_digest: activation_digest.clone(),
1601                record_id: record_id.clone(),
1602                session: session.clone(),
1603                call_id: call_id.clone(),
1604                next_offset: *next_offset,
1605                completed: *completed,
1606            },
1607            NodeResponse::CallRejected {
1608                reservation_id,
1609                activation_digest,
1610                record_id,
1611                session,
1612                call_id,
1613            } => Self::CallRejected {
1614                reservation_id: reservation_id.clone(),
1615                activation_digest: activation_digest.clone(),
1616                record_id: record_id.clone(),
1617                session: session.clone(),
1618                call_id: call_id.clone(),
1619            },
1620            NodeResponse::DeliveryStageBegun {
1621                stage_id,
1622                manifest_digest,
1623                missing_blobs,
1624            } => Self::DeliveryStageBegun {
1625                stage_id: stage_id.clone(),
1626                manifest_digest: manifest_digest.clone(),
1627                missing_blobs: missing_blobs.clone(),
1628            },
1629            NodeResponse::DeliveryBlobChunkAccepted {
1630                stage_id,
1631                blob_digest,
1632                next_offset,
1633            } => Self::DeliveryBlobChunkAccepted {
1634                stage_id: stage_id.clone(),
1635                blob_digest: blob_digest.clone(),
1636                next_offset: *next_offset,
1637            },
1638            NodeResponse::DeliveryCommitted { receipt } => Self::DeliveryCommitted {
1639                receipt: receipt.clone(),
1640            },
1641            NodeResponse::DeliveryStageAborted { stage_id } => Self::DeliveryStageAborted {
1642                stage_id: stage_id.clone(),
1643            },
1644            NodeResponse::WorkspaceInspected { inspection } => Self::WorkspaceInspected {
1645                inspection: C2WorkspaceInspection::from(inspection),
1646            },
1647            NodeResponse::HostDirectoriesBrowsed { listing } => Self::HostDirectoriesBrowsed {
1648                listing: listing.clone(),
1649            },
1650            NodeResponse::WorkspaceFileRead { file } => Self::WorkspaceFileRead {
1651                file: file.clone(),
1652            },
1653            NodeResponse::WorkspaceFileWritten { file } => Self::WorkspaceFileWritten {
1654                file: file.clone(),
1655            },
1656            NodeResponse::WorkspaceFileCreated { file } => Self::WorkspaceFileCreated {
1657                file: file.clone(),
1658            },
1659            NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry } => {
1660                Self::WorkspaceDirectoryCreated {
1661                    workspace_id: workspace_id.clone(),
1662                    entry: entry.clone(),
1663                }
1664            }
1665            NodeResponse::GitHistoryRead { workspace_id, page } => Self::GitHistoryRead {
1666                workspace_id: workspace_id.clone(),
1667                page: page.clone(),
1668            },
1669            NodeResponse::GitDiffRead { workspace_id, diff } => Self::GitDiffRead {
1670                workspace_id: workspace_id.clone(),
1671                diff: diff.clone(),
1672            },
1673            NodeResponse::Controller { controller } => Self::Controller {
1674                controller: controller.clone(),
1675            },
1676            NodeResponse::SpawnAccepted { session } => Self::SpawnAccepted { session: session.clone() },
1677            NodeResponse::SpawnSpecAccepted { receipt } => Self::SpawnSpecAccepted {
1678                receipt: receipt.clone(),
1679            },
1680            NodeResponse::ManagedWorktreeSpawnAccepted { receipt } => {
1681                Self::ManagedWorktreeSpawnAccepted {
1682                    receipt: receipt.clone(),
1683                }
1684            }
1685            NodeResponse::ManagedWorktreeCleanup { lease } => Self::ManagedWorktreeCleanup {
1686                lease: lease.clone(),
1687            },
1688            NodeResponse::SessionRecordUpdated { record } => Self::SessionRecordUpdated {
1689                record: C2ManagedSessionRecord::from(record),
1690            },
1691            NodeResponse::ProviderSessionIndexed { record } => Self::ProviderSessionIndexed {
1692                record: C2ManagedSessionRecord::from(record),
1693            },
1694            NodeResponse::NativeSessionIndexed { selection, record } => {
1695                Self::NativeSessionIndexed {
1696                    selection: selection.clone(),
1697                    record: C2ManagedSessionRecord::from(record),
1698                }
1699            }
1700            NodeResponse::SessionRecordResumed { record, session } => Self::SessionRecordResumed {
1701                record: C2ManagedSessionRecord::from(record),
1702                session: session.clone(),
1703            },
1704            NodeResponse::SessionRecordForgotten { record_id } => Self::SessionRecordForgotten {
1705                record_id: record_id.clone(),
1706            },
1707            NodeResponse::NativeSessionsCataloged {
1708                route,
1709                entries,
1710                summary,
1711            } => Self::NativeSessionsCataloged {
1712                route: route.clone(),
1713                entries: entries.clone(),
1714                summary: summary.clone(),
1715            },
1716            NodeResponse::NativeSessionsPaged {
1717                route,
1718                page,
1719            } => Self::NativeSessionsPaged {
1720                route: route.clone(),
1721                page: page.clone(),
1722            },
1723            NodeResponse::NativeSessionPreviewed {
1724                selection,
1725                preview,
1726            } => Self::NativeSessionPreviewed {
1727                selection: selection.clone(),
1728                preview: preview.clone(),
1729            },
1730            NodeResponse::SessionRecordPreviewed { record_id, preview } => {
1731                Self::SessionRecordPreviewed {
1732                    record_id: record_id.clone(),
1733                    preview: preview.clone(),
1734                }
1735            }
1736            NodeResponse::HistoryDiscovered { session, candidates } => Self::HistoryDiscovered {
1737                session: session.clone(),
1738                candidates: candidates.clone(),
1739            },
1740            NodeResponse::HistoryLoaded {
1741                session,
1742                session_id,
1743                message_count,
1744                completed_turn_count,
1745            } => Self::HistoryLoaded {
1746                session: session.clone(),
1747                session_id: session_id.clone(),
1748                message_count: *message_count,
1749                completed_turn_count: *completed_turn_count,
1750            },
1751            NodeResponse::ContextPackExported { context } => Self::ContextPackExported {
1752                context: context.clone(),
1753            },
1754            NodeResponse::ContextPackForSessionRecordExported {
1755                record_id,
1756                session,
1757                context,
1758            } => Self::ContextPackForSessionRecordExported {
1759                record_id: record_id.clone(),
1760                session: session.clone(),
1761                context: context.clone(),
1762            },
1763            NodeResponse::ContextPackForgotten { context_id } => Self::ContextPackForgotten {
1764                context_id: context_id.clone(),
1765            },
1766            NodeResponse::DurableContextPackResolved { context } => {
1767                Self::DurableContextPackResolved {
1768                    context: context.clone(),
1769                }
1770            }
1771            NodeResponse::ContextPackBytesRead { pack } => Self::ContextPackBytesRead { pack: pack.clone() },
1772            NodeResponse::WorkspaceRegistered { workspace } => Self::WorkspaceRegistered {
1773                workspace: C2WorkspaceSnapshot::from(workspace),
1774            },
1775            NodeResponse::StandaloneWorkspaceCreated { workspace } => {
1776                Self::StandaloneWorkspaceCreated {
1777                    workspace: C2WorkspaceSnapshot::from(workspace),
1778                }
1779            }
1780            NodeResponse::WorkspaceUnregistered { workspace_id } => Self::WorkspaceUnregistered {
1781                workspace_id: workspace_id.clone(),
1782            },
1783            NodeResponse::WorktreeCreated { worktree, workspace } => Self::WorktreeCreated {
1784                worktree: C2GitWorktreeSnapshot::from(worktree),
1785                workspace: C2WorkspaceSnapshot::from(workspace),
1786            },
1787            NodeResponse::WorktreeRemoved { target_root, workspace_id } => Self::WorktreeRemoved {
1788                target_root: target_root.clone(),
1789                workspace_id: workspace_id.clone(),
1790            },
1791            NodeResponse::Accepted => Self::Accepted,
1792            NodeResponse::ShuttingDown => Self::ShuttingDown,
1793        }
1794    }
1795}
1796
1797impl C2NodeResponse {
1798    /// Like `From`, but the control events of a `Resync` keep their sanitized
1799    /// telemetry detail; see [`control_telemetry_detail`].
1800    pub fn from_node_response_with_control_detail(response: &NodeResponse) -> Self {
1801        let mut projected = Self::from(response);
1802        if let (
1803            Self::Resync { events: projected_events, .. },
1804            NodeResponse::Resync { events, .. },
1805        ) = (&mut projected, response)
1806        {
1807            *projected_events = events
1808                .iter()
1809                .map(C2NodeEventEnvelope::from_node_envelope_with_control_detail)
1810                .collect();
1811        }
1812        projected
1813    }
1814
1815    pub fn requires_control_detail_capability(&self) -> bool {
1816        matches!(self, Self::Resync { events, .. }
1817            if events.iter().any(|event| event.event.requires_control_detail_capability()))
1818    }
1819
1820    pub fn strip_control_detail(&mut self) {
1821        if let Self::Resync { events, .. } = self {
1822            for event in events {
1823                event.event.strip_control_detail();
1824            }
1825        }
1826    }
1827
1828    pub fn requires_session_record_context_export_capability(&self) -> bool {
1829        matches!(self, Self::ContextPackForSessionRecordExported { .. })
1830    }
1831
1832    pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
1833        matches!(self,
1834            Self::Armed { .. }
1835                | Self::Spawned { .. }
1836                | Self::Activated { .. }
1837                | Self::Aborted { .. }
1838                | Self::ReplyChunkAccepted { .. }
1839                | Self::CallRejected { .. })
1840            || matches!(self, Self::SpawnSpecAccepted { receipt }
1841                if receipt.harness_mcp_proxy.is_some())
1842            || matches!(self, Self::ManagedWorktreeSpawnAccepted { receipt }
1843                if receipt.spawn.harness_mcp_proxy.is_some())
1844    }
1845}
1846
1847impl C2NodeResponse {
1848    pub fn native_session_catalog_contract_is_valid(&self) -> bool {
1849        match self {
1850            Self::NativeSessionsCataloged { route, entries, summary } => {
1851                route.validate().is_ok()
1852                    && entries.len()
1853                        <= usize::from(gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX)
1854                    && entries.iter().enumerate().all(|(index, entry)| {
1855                        entry.validate_for_route(route).is_ok()
1856                            && !entries[..index].iter().any(|existing| {
1857                                existing.selection_id == entry.selection_id
1858                                    || entry.record_id.as_ref().is_some_and(|record_id| {
1859                                        existing.record_id.as_ref() == Some(record_id)
1860                                    })
1861                            })
1862                    })
1863                    && summary.as_ref().map_or(true, |summary| {
1864                        summary.validate_initial_entries(entries.len()).is_ok()
1865                    })
1866            }
1867            Self::NativeSessionsPaged { route, page } => {
1868                page.validate_for_route(route).is_ok()
1869            }
1870            _ => true,
1871        }
1872    }
1873
1874    pub fn requires_native_session_catalog_capability(&self) -> bool {
1875        matches!(self, Self::NativeSessionsCataloged { .. })
1876    }
1877
1878    pub fn requires_native_session_catalog_paging_capability(&self) -> bool {
1879        matches!(self, Self::NativeSessionsPaged { .. })
1880    }
1881
1882    pub fn requires_native_session_preview_capability(&self) -> bool {
1883        matches!(
1884            self,
1885            Self::NativeSessionPreviewed { .. } | Self::SessionRecordPreviewed { .. }
1886        )
1887    }
1888
1889    pub fn requires_native_session_index_capability(&self) -> bool {
1890        matches!(self, Self::NativeSessionIndexed { .. })
1891    }
1892
1893    pub fn native_session_index_contract_is_valid(&self) -> bool {
1894        match self {
1895            Self::NativeSessionIndexed { selection, record } => {
1896                selection.validate().is_ok()
1897                    && selection.route.scope == NativeSessionCatalogScope::Workspace
1898                    && selection.route.workspace_id.as_ref() == Some(&record.workspace_id)
1899                    && selection.route.provider == record.provider
1900            }
1901            _ => false,
1902        }
1903    }
1904
1905    pub fn native_session_preview_contract_is_valid(&self) -> bool {
1906        match self {
1907            Self::NativeSessionPreviewed { selection, preview } => {
1908                selection.validate().is_ok() && preview.validate().is_ok()
1909            }
1910            Self::SessionRecordPreviewed { preview, .. } => preview.validate().is_ok(),
1911            _ => true,
1912        }
1913    }
1914
1915    pub fn requires_host_directory_browse_capability(&self) -> bool {
1916        matches!(self, Self::HostDirectoriesBrowsed { .. })
1917    }
1918
1919    pub fn requires_workspace_entry_create_capability(&self) -> bool {
1920        matches!(
1921            self,
1922            Self::WorkspaceFileCreated { .. } | Self::WorkspaceDirectoryCreated { .. }
1923        )
1924    }
1925
1926    pub fn requires_child_environment_profile_capability(&self) -> bool {
1927        match self {
1928            Self::Snapshot { snapshot, .. } => {
1929                snapshot.requires_child_environment_profile_capability()
1930            }
1931            Self::Resync {
1932                snapshot, events, ..
1933            } => {
1934                snapshot.requires_child_environment_profile_capability()
1935                    || events.iter().any(|event| {
1936                        event.event.requires_child_environment_profile_capability()
1937                    })
1938            }
1939            Self::SpawnSpecAccepted { receipt }
1940            | Self::Spawned { receipt, .. } => receipt.environment_profile.is_some(),
1941            Self::ManagedWorktreeSpawnAccepted { receipt } => {
1942                receipt.spawn.environment_profile.is_some()
1943            }
1944            Self::SessionRecordUpdated { record }
1945            | Self::ProviderSessionIndexed { record }
1946            | Self::NativeSessionIndexed { record, .. }
1947            | Self::SessionRecordResumed { record, .. } => {
1948                record.environment_profile.is_some()
1949            }
1950            Self::DurableContextPackResolved { .. } | Self::ContextPackBytesRead { .. } => false,
1951            Self::Armed { .. }
1952            | Self::Activated { .. }
1953            | Self::Aborted { .. }
1954            | Self::ReplyChunkAccepted { .. }
1955            | Self::CallRejected { .. }
1956            | Self::WorkspaceInspected { .. }
1957            | Self::DeliveryStageBegun { .. }
1958            | Self::DeliveryBlobChunkAccepted { .. }
1959            | Self::DeliveryCommitted { .. }
1960            | Self::DeliveryStageAborted { .. }
1961            | Self::HostDirectoriesBrowsed { .. }
1962            | Self::WorkspaceFileRead { .. }
1963            | Self::WorkspaceFileWritten { .. }
1964            | Self::WorkspaceFileCreated { .. }
1965            | Self::WorkspaceDirectoryCreated { .. }
1966            | Self::GitHistoryRead { .. }
1967            | Self::GitDiffRead { .. }
1968            | Self::Controller { .. }
1969            | Self::SpawnAccepted { .. }
1970            | Self::ManagedWorktreeCleanup { .. }
1971            | Self::SessionRecordForgotten { .. }
1972            | Self::NativeSessionsCataloged { .. }
1973            | Self::NativeSessionsPaged { .. }
1974            | Self::NativeSessionPreviewed { .. }
1975            | Self::SessionRecordPreviewed { .. }
1976            | Self::HistoryDiscovered { .. }
1977            | Self::HistoryLoaded { .. }
1978            | Self::ContextPackForSessionRecordExported { .. }
1979            | Self::ContextPackExported { .. }
1980            | Self::ContextPackForgotten { .. }
1981            | Self::WorkspaceRegistered { .. }
1982            | Self::StandaloneWorkspaceCreated { .. }
1983            | Self::WorkspaceUnregistered { .. }
1984            | Self::WorktreeCreated { .. }
1985            | Self::WorktreeRemoved { .. }
1986            | Self::Accepted
1987            | Self::ShuttingDown => false,
1988        }
1989    }
1990
1991    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
1992        match self {
1993            Self::Snapshot { snapshot, .. } => {
1994                snapshot.requires_session_bundle_materialization_capability()
1995            }
1996            Self::Resync {
1997                snapshot, events, ..
1998            } => {
1999                snapshot.requires_session_bundle_materialization_capability()
2000                    || events.iter().any(|event| {
2001                        event.event.requires_session_bundle_materialization_capability()
2002                    })
2003            }
2004            Self::SpawnSpecAccepted { receipt }
2005            | Self::Spawned { receipt, .. } => receipt.bundle.is_some(),
2006            Self::ManagedWorktreeSpawnAccepted { receipt } => receipt.spawn.bundle.is_some(),
2007            Self::SessionRecordUpdated { record }
2008            | Self::ProviderSessionIndexed { record }
2009            | Self::NativeSessionIndexed { record, .. }
2010            | Self::SessionRecordResumed { record, .. } => record.bundle.is_some(),
2011            Self::DurableContextPackResolved { .. } | Self::ContextPackBytesRead { .. } => false,
2012            Self::Armed { .. }
2013            | Self::Activated { .. }
2014            | Self::Aborted { .. }
2015            | Self::ReplyChunkAccepted { .. }
2016            | Self::CallRejected { .. }
2017            | Self::WorkspaceInspected { .. }
2018            | Self::DeliveryStageBegun { .. }
2019            | Self::DeliveryBlobChunkAccepted { .. }
2020            | Self::DeliveryCommitted { .. }
2021            | Self::DeliveryStageAborted { .. }
2022            | Self::HostDirectoriesBrowsed { .. }
2023            | Self::WorkspaceFileRead { .. }
2024            | Self::WorkspaceFileWritten { .. }
2025            | Self::WorkspaceFileCreated { .. }
2026            | Self::WorkspaceDirectoryCreated { .. }
2027            | Self::GitHistoryRead { .. }
2028            | Self::GitDiffRead { .. }
2029            | Self::Controller { .. }
2030            | Self::SpawnAccepted { .. }
2031            | Self::ManagedWorktreeCleanup { .. }
2032            | Self::SessionRecordForgotten { .. }
2033            | Self::NativeSessionsCataloged { .. }
2034            | Self::NativeSessionsPaged { .. }
2035            | Self::NativeSessionPreviewed { .. }
2036            | Self::SessionRecordPreviewed { .. }
2037            | Self::HistoryDiscovered { .. }
2038            | Self::HistoryLoaded { .. }
2039            | Self::ContextPackForSessionRecordExported { .. }
2040            | Self::ContextPackExported { .. }
2041            | Self::ContextPackForgotten { .. }
2042            | Self::WorkspaceRegistered { .. }
2043            | Self::StandaloneWorkspaceCreated { .. }
2044            | Self::WorkspaceUnregistered { .. }
2045            | Self::WorktreeCreated { .. }
2046            | Self::WorktreeRemoved { .. }
2047            | Self::Accepted
2048            | Self::ShuttingDown => false,
2049        }
2050    }
2051
2052    pub fn requires_history_context_pack_capability(&self) -> bool {
2053        match self {
2054            Self::Snapshot { snapshot, .. } => {
2055                snapshot.requires_history_context_pack_capability()
2056            }
2057            Self::Resync {
2058                snapshot, events, ..
2059            } => {
2060                snapshot.requires_history_context_pack_capability()
2061                    || events.iter().any(|event| {
2062                        event.event.requires_history_context_pack_capability()
2063                    })
2064            }
2065            Self::SpawnSpecAccepted { receipt }
2066            | Self::Spawned { receipt, .. } => {
2067                receipt.context_id.is_some() || receipt.context.is_some()
2068            }
2069            Self::ManagedWorktreeSpawnAccepted { receipt } => {
2070                receipt.spawn.context_id.is_some() || receipt.spawn.context.is_some()
2071            }
2072            Self::SessionRecordUpdated { record }
2073            | Self::ProviderSessionIndexed { record }
2074            | Self::NativeSessionIndexed { record, .. }
2075            | Self::SessionRecordResumed { record, .. } => {
2076                record.requires_history_context_pack_capability()
2077            }
2078            Self::HistoryDiscovered { .. }
2079            | Self::HistoryLoaded { .. }
2080            | Self::ContextPackForSessionRecordExported { .. }
2081            | Self::ContextPackExported { .. }
2082            | Self::ContextPackForgotten { .. }
2083            | Self::DurableContextPackResolved { .. }
2084            | Self::ContextPackBytesRead { .. } => true,
2085            Self::Armed { .. }
2086            | Self::Activated { .. }
2087            | Self::Aborted { .. }
2088            | Self::ReplyChunkAccepted { .. }
2089            | Self::CallRejected { .. }
2090            | Self::WorkspaceInspected { .. }
2091            | Self::DeliveryStageBegun { .. }
2092            | Self::DeliveryBlobChunkAccepted { .. }
2093            | Self::DeliveryCommitted { .. }
2094            | Self::DeliveryStageAborted { .. }
2095            | Self::HostDirectoriesBrowsed { .. }
2096            | Self::WorkspaceFileRead { .. }
2097            | Self::WorkspaceFileWritten { .. }
2098            | Self::WorkspaceFileCreated { .. }
2099            | Self::WorkspaceDirectoryCreated { .. }
2100            | Self::GitHistoryRead { .. }
2101            | Self::GitDiffRead { .. }
2102            | Self::Controller { .. }
2103            | Self::SpawnAccepted { .. }
2104            | Self::ManagedWorktreeCleanup { .. }
2105            | Self::SessionRecordForgotten { .. }
2106            | Self::NativeSessionsCataloged { .. }
2107            | Self::NativeSessionsPaged { .. }
2108            | Self::NativeSessionPreviewed { .. }
2109            | Self::SessionRecordPreviewed { .. }
2110            | Self::WorkspaceRegistered { .. }
2111            | Self::StandaloneWorkspaceCreated { .. }
2112            | Self::WorkspaceUnregistered { .. }
2113            | Self::WorktreeCreated { .. }
2114            | Self::WorktreeRemoved { .. }
2115            | Self::Accepted
2116            | Self::ShuttingDown => false,
2117        }
2118    }
2119
2120    pub fn requires_session_task_correlation_capability(&self) -> bool {
2121        match self {
2122            Self::Snapshot { snapshot, .. } => {
2123                snapshot.requires_session_task_correlation_capability()
2124            }
2125            Self::Resync { snapshot, events, .. } => {
2126                snapshot.requires_session_task_correlation_capability()
2127                    || events.iter().any(|event| {
2128                        event.event.requires_session_task_correlation_capability()
2129                    })
2130            }
2131            Self::SessionRecordUpdated { record }
2132            | Self::ProviderSessionIndexed { record }
2133            | Self::NativeSessionIndexed { record, .. }
2134            | Self::SessionRecordResumed { record, .. } => {
2135                record.requires_session_task_correlation_capability()
2136            }
2137            _ => false,
2138        }
2139    }
2140}
2141
2142fn deserialize_c2_native_session_catalog_entries<'de, D>(
2143    deserializer: D,
2144) -> Result<Vec<NativeSessionCatalogEntry>, D::Error>
2145where
2146    D: Deserializer<'de>,
2147{
2148    let entries = Vec::<NativeSessionCatalogEntry>::deserialize(deserializer)?;
2149    if entries.len() > usize::from(gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX) {
2150        return Err(serde::de::Error::custom(
2151            "C2 native session catalog exceeds the supported bounded range",
2152        ));
2153    }
2154    for (index, entry) in entries.iter().enumerate() {
2155        entry.validate().map_err(serde::de::Error::custom)?;
2156        if entries[..index]
2157            .iter()
2158            .any(|existing| {
2159                existing.selection_id == entry.selection_id
2160                    || entry.record_id.as_ref().is_some_and(|record_id| {
2161                        existing.record_id.as_ref() == Some(record_id)
2162                    })
2163            })
2164        {
2165            return Err(serde::de::Error::custom(
2166                "C2 native session catalog contains a duplicate selection or managed record",
2167            ));
2168        }
2169    }
2170    Ok(entries)
2171}
2172
2173fn deserialize_optional_c2_native_session_catalog_summary<'de, D>(
2174    deserializer: D,
2175) -> Result<Option<NativeSessionCatalogSummary>, D::Error>
2176where
2177    D: Deserializer<'de>,
2178{
2179    let summary = Option::<NativeSessionCatalogSummary>::deserialize(deserializer)?;
2180    if let Some(summary) = summary.as_ref() {
2181        summary.validate().map_err(serde::de::Error::custom)?;
2182    }
2183    Ok(summary)
2184}
2185
2186fn deserialize_c2_native_session_catalog_page<'de, D>(
2187    deserializer: D,
2188) -> Result<NativeSessionCatalogPage, D::Error>
2189where
2190    D: Deserializer<'de>,
2191{
2192    let page = NativeSessionCatalogPage::deserialize(deserializer)?;
2193    page.validate().map_err(serde::de::Error::custom)?;
2194    Ok(page)
2195}
2196
2197fn deserialize_c2_native_session_preview<'de, D>(
2198    deserializer: D,
2199) -> Result<NativeSessionPreview, D::Error>
2200where
2201    D: Deserializer<'de>,
2202{
2203    let preview = NativeSessionPreview::deserialize(deserializer)?;
2204    preview.validate().map_err(serde::de::Error::custom)?;
2205    Ok(preview)
2206}
2207
2208fn deserialize_c2_session_record_preview<'de, D>(
2209    deserializer: D,
2210) -> Result<SessionRecordPreview, D::Error>
2211where
2212    D: Deserializer<'de>,
2213{
2214    let preview = SessionRecordPreview::deserialize(deserializer)?;
2215    preview.validate().map_err(serde::de::Error::custom)?;
2216    Ok(preview)
2217}
2218
2219fn deserialize_c2_history_candidates<'de, D>(
2220    deserializer: D,
2221) -> Result<Vec<HistoryCandidateSummary>, D::Error>
2222where
2223    D: Deserializer<'de>,
2224{
2225    let candidates = Vec::<HistoryCandidateSummary>::deserialize(deserializer)?;
2226    if candidates.len() > usize::from(gate4agent_types::HISTORY_DISCOVERY_LIMIT_MAX) {
2227        return Err(serde::de::Error::custom(
2228            "C2 history candidate count exceeds the discovery limit",
2229        ));
2230    }
2231    for (index, candidate) in candidates.iter().enumerate() {
2232        candidate.validate().map_err(serde::de::Error::custom)?;
2233        if candidates[..index]
2234            .iter()
2235            .any(|existing| existing.id == candidate.id)
2236        {
2237            return Err(serde::de::Error::custom(
2238                "C2 history candidates contain a duplicate candidate ID",
2239            ));
2240        }
2241    }
2242    Ok(candidates)
2243}
2244
2245fn deserialize_c2_history_session_id<'de, D>(deserializer: D) -> Result<String, D::Error>
2246where
2247    D: Deserializer<'de>,
2248{
2249    let session_id = String::deserialize(deserializer)?;
2250    let validation = gate4agent_types::HistorySessionRecord {
2251        session_id: session_id.clone(),
2252        title: None,
2253        cwd: None,
2254        model: None,
2255        message_count: 0,
2256        completed_turn_count: None,
2257        total_tokens: 0,
2258        messages: Vec::new(),
2259    };
2260    validation.validate().map_err(serde::de::Error::custom)?;
2261    Ok(session_id)
2262}
2263
2264#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2265#[serde(rename_all = "kebab-case")]
2266pub enum C2RelayFailureCode {
2267    UnknownNode,
2268    NodeOffline,
2269    StaleNodeIncarnation,
2270    RelayBusy,
2271    OperatorAlreadyConnected,
2272    RequestIdReused,
2273    RequestForbidden,
2274    ClientLagged,
2275    ShuttingDown,
2276    /// The connecting side's [`BUILD_STAMP`] did not match this side's own
2277    /// -- carries nothing itself; the accompanying `C2RelayFailure::
2278    /// message` names both stamps (`"build stamp mismatch: local=<s>
2279    /// remote=<s>"`), the same "both values, one text" idiom the harness
2280    /// operator/read wires already use for their own build-stamp
2281    /// mismatches. Sent pre-handshake, before the connection is closed, so
2282    /// the peer sees a named refusal instead of a bare disconnect.
2283    BuildStampMismatch,
2284}
2285
2286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2287pub struct C2RelayFailure {
2288    pub code: C2RelayFailureCode,
2289    pub message: String,
2290    pub current_incarnation_id: Option<NodeIncarnationId>,
2291}
2292
2293#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2294pub struct C2ClientHello {
2295    pub build_stamp: String,
2296    pub client_nonce: [u8; C2_AUTH_NONCE_BYTES],
2297    #[serde(default, skip_serializing_if = "Option::is_none")]
2298    pub compatibility: Option<ClientCompatibilityOffer>,
2299}
2300
2301impl C2ClientHello {
2302    pub fn new(client_nonce: [u8; C2_AUTH_NONCE_BYTES]) -> Self {
2303        Self {
2304            build_stamp: BUILD_STAMP.to_owned(),
2305            client_nonce,
2306            compatibility: None,
2307        }
2308    }
2309
2310    pub fn negotiating(
2311        client_nonce: [u8; C2_AUTH_NONCE_BYTES],
2312        compatibility: ClientCompatibilityOffer,
2313    ) -> Self {
2314        Self {
2315            build_stamp: BUILD_STAMP.to_owned(),
2316            client_nonce,
2317            compatibility: Some(compatibility),
2318        }
2319    }
2320}
2321
2322#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2323pub struct C2ControlCompatibilitySupport {
2324    pub build_stamp: String,
2325    #[serde(default)]
2326    pub capabilities: Vec<CapabilityId>,
2327    pub host: HostDescriptor,
2328    pub path_semantics: PathSemantics,
2329}
2330
2331#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2332pub struct NegotiatedC2ControlCompatibility {
2333    pub build_stamp: String,
2334    #[serde(default)]
2335    pub capabilities: Vec<CapabilityId>,
2336    pub host: HostDescriptor,
2337    pub path_semantics: PathSemantics,
2338}
2339
2340impl C2ControlCompatibilitySupport {
2341    pub fn negotiate(
2342        &self,
2343        hello: &C2ClientHello,
2344    ) -> Result<NegotiatedC2ControlCompatibility, ProtocolNegotiationError> {
2345        if self.build_stamp != hello.build_stamp {
2346            return Err(ProtocolNegotiationError::BuildStampMismatch {
2347                local: self.build_stamp.clone(),
2348                remote: hello.build_stamp.clone(),
2349            });
2350        }
2351        let legacy;
2352        let offer = match hello.compatibility.as_ref() {
2353            Some(offer) => offer,
2354            None => {
2355                legacy = ClientCompatibilityOffer::local();
2356                &legacy
2357            }
2358        };
2359        let capabilities = self
2360            .capabilities
2361            .iter()
2362            .filter(|capability| offer.capabilities.contains(capability))
2363            .cloned()
2364            .collect();
2365        Ok(NegotiatedC2ControlCompatibility {
2366            build_stamp: self.build_stamp.clone(),
2367            capabilities,
2368            host: self.host.clone(),
2369            path_semantics: self.path_semantics.clone(),
2370        })
2371    }
2372}
2373
2374#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2375pub struct C2ServerChallenge {
2376    pub build_stamp: String,
2377    pub server_nonce: [u8; C2_AUTH_NONCE_BYTES],
2378    pub server_proof: [u8; C2_AUTH_PROOF_BYTES],
2379    #[serde(default, skip_serializing_if = "Option::is_none")]
2380    pub compatibility: Option<NegotiatedC2ControlCompatibility>,
2381}
2382
2383#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2384pub struct C2ClientAuthentication {
2385    pub client_proof: [u8; C2_AUTH_PROOF_BYTES],
2386}
2387
2388#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2389pub struct C2Hello {
2390    pub build_stamp: String,
2391    pub connection_id: u64,
2392    pub status: StatusResponse,
2393    #[serde(default, skip_serializing_if = "Option::is_none")]
2394    pub compatibility: Option<NegotiatedC2ControlCompatibility>,
2395}
2396
2397#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2398#[serde(rename_all = "kebab-case")]
2399pub enum C2RelayRoute {
2400    #[default]
2401    Unknown,
2402    LocalIpc,
2403    SshForwardedLoopback,
2404}
2405
2406impl C2RelayRoute {
2407    pub const fn is_unknown(&self) -> bool {
2408        matches!(self, Self::Unknown)
2409    }
2410
2411    fn from_transport_label(transport_label: &str) -> Self {
2412        match transport_label {
2413            "windows-named-pipe" | "unix-domain-socket" => Self::LocalIpc,
2414            "ssh-forwarded-loopback" => Self::SshForwardedLoopback,
2415            _ => Self::Unknown,
2416        }
2417    }
2418}
2419
2420#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2421pub struct C2TopologyNode {
2422    pub node_id: NodeId,
2423    pub endpoint: String,
2424    #[serde(default, skip_serializing_if = "C2RelayRoute::is_unknown")]
2425    pub relay_route: C2RelayRoute,
2426    pub transport: NodeTransportState,
2427    pub current_incarnation_id: Option<NodeIncarnationId>,
2428    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2429    pub provider_contracts: Vec<ProviderContractSupport>,
2430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2431    pub provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
2432    #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
2433    pub provider_runtime_statuses: ProviderRuntimeStatuses,
2434}
2435
2436#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2437pub struct C2Topology {
2438    pub nodes: Vec<C2TopologyNode>,
2439}
2440
2441impl C2Topology {
2442    pub fn from_status(status: &StatusResponse) -> Self {
2443        Self::from_status_with_capabilities(status, true, true)
2444    }
2445
2446    pub fn from_status_with_provider_contracts(
2447        status: &StatusResponse,
2448        include_provider_contracts: bool,
2449    ) -> Self {
2450        Self::from_status_with_capabilities(
2451            status,
2452            include_provider_contracts,
2453            include_provider_contracts,
2454        )
2455    }
2456
2457    pub fn from_status_with_capabilities(
2458        status: &StatusResponse,
2459        include_provider_contracts: bool,
2460        include_provider_runtime_status: bool,
2461    ) -> Self {
2462        let nodes = status.nodes.iter().take(MAX_C2_NODES).map(|(node_id, observed)| {
2463            let provider_contract_inventory = include_provider_contracts
2464                .then_some(observed.inventory.as_ref())
2465                .flatten();
2466            let provider_runtime_statuses = if include_provider_runtime_status {
2467                observed
2468                    .inventory
2469                    .as_ref()
2470                    .map(|inventory| inventory.provider_runtime_statuses.clone())
2471                    .unwrap_or_default()
2472            } else {
2473                ProviderRuntimeStatuses::default()
2474            };
2475            C2TopologyNode {
2476                node_id: node_id.clone(),
2477                endpoint: observed.endpoint.clone(),
2478                relay_route: C2RelayRoute::from_transport_label(&observed.transport_label),
2479                transport: observed.transport,
2480                current_incarnation_id: observed.cursor.map(|cursor| cursor.incarnation_id),
2481                provider_contracts: provider_contract_inventory
2482                    .map(|inventory| inventory.provider_contracts.clone())
2483                    .unwrap_or_default(),
2484                provider_adapter_contracts: provider_contract_inventory
2485                    .map(|inventory| inventory.provider_adapter_contracts.clone())
2486                    .unwrap_or_default(),
2487                provider_runtime_statuses,
2488            }
2489        }).collect();
2490        Self { nodes }
2491    }
2492}
2493
2494#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2495pub struct C2RequestEnvelope {
2496    pub request_id: C2RequestId,
2497    pub request: RoutedNodeRequest,
2498}
2499
2500#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2501pub struct C2ReplyEnvelope {
2502    pub request_id: C2RequestId,
2503    pub result: Result<RoutedNodeResponse, C2RelayFailure>,
2504}
2505
2506#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2507#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
2508pub enum C2ClientFrame {
2509    Hello(C2ClientHello),
2510    Authenticate(C2ClientAuthentication),
2511    Request(C2RequestEnvelope),
2512}
2513
2514#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2515#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
2516pub enum C2ServerFrame {
2517    Challenge(C2ServerChallenge),
2518    Hello(C2Hello),
2519    Reply(C2ReplyEnvelope),
2520    Event(RoutedNodeEvent),
2521    Topology(C2Topology),
2522    Rejected(C2RelayFailure),
2523}
2524
2525#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2526pub enum C2AuthDirection {
2527    Server,
2528    Client,
2529}
2530
2531pub fn c2_auth_transcript(
2532    direction: C2AuthDirection,
2533    client_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2534    server_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2535) -> Vec<u8> {
2536    let mut message = Vec::with_capacity(32 + (C2_AUTH_NONCE_BYTES * 2));
2537    message.extend_from_slice(b"gate4agent-c2-control-auth-v2\0");
2538    encode_bounded_str(&mut message, BUILD_STAMP);
2539    message.push(match direction { C2AuthDirection::Server => 1, C2AuthDirection::Client => 2 });
2540    message.extend_from_slice(client_nonce);
2541    message.extend_from_slice(server_nonce);
2542    message
2543}
2544
2545#[derive(Clone, Debug, Eq, PartialEq)]
2546pub enum C2AuthTranscriptError {
2547    TooManyCapabilities {
2548        section: &'static str,
2549        count: usize,
2550        max: usize,
2551    },
2552    TooLong {
2553        len: usize,
2554        max: usize,
2555    },
2556}
2557
2558impl fmt::Display for C2AuthTranscriptError {
2559    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2560        match self {
2561            Self::TooManyCapabilities { section, count, max } => write!(
2562                formatter,
2563                "{section} contains {count} capabilities, exceeding the {max}-entry authentication limit",
2564            ),
2565            Self::TooLong { len, max } => write!(
2566                formatter,
2567                "C2 compatibility authentication transcript is {len} bytes, exceeding the {max}-byte limit",
2568            ),
2569        }
2570    }
2571}
2572
2573impl std::error::Error for C2AuthTranscriptError {}
2574
2575pub fn c2_bound_auth_transcript(
2576    direction: C2AuthDirection,
2577    client_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2578    server_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2579    offer: &ClientCompatibilityOffer,
2580    selected: &NegotiatedC2ControlCompatibility,
2581) -> Result<Vec<u8>, C2AuthTranscriptError> {
2582    validate_auth_capabilities("offer", &offer.capabilities)?;
2583    validate_auth_capabilities("selection", &selected.capabilities)?;
2584
2585    let mut message = Vec::with_capacity(512);
2586    message.extend_from_slice(b"gate4agent-c2-control-auth-v2-compatibility\0");
2587    encode_bounded_str(&mut message, BUILD_STAMP);
2588    message.push(match direction { C2AuthDirection::Server => 1, C2AuthDirection::Client => 2 });
2589    message.extend_from_slice(client_nonce);
2590    message.extend_from_slice(server_nonce);
2591
2592    message.extend_from_slice(b"offer\0");
2593    encode_bounded_str(&mut message, &offer.build_stamp);
2594    encode_capabilities(&mut message, &offer.capabilities);
2595    match offer.state_schema {
2596        Some(state_schema) => {
2597            message.push(1);
2598            encode_protocol_range(&mut message, state_schema.versions);
2599        }
2600        None => message.push(0),
2601    }
2602
2603    message.extend_from_slice(b"selected\0");
2604    encode_bounded_str(&mut message, &selected.build_stamp);
2605    encode_capabilities(&mut message, &selected.capabilities);
2606    encode_bounded_str(&mut message, selected.host.operating_system.as_str());
2607    encode_bounded_str(&mut message, selected.host.architecture.as_str());
2608    message.push(match selected.path_semantics.style {
2609        PathStyle::Windows => 1,
2610        PathStyle::Posix => 2,
2611    });
2612    message.push(match selected.path_semantics.encoding {
2613        PathEncoding::Utf8 => 1,
2614        PathEncoding::UnixBytes => 2,
2615    });
2616
2617    if message.len() > MAX_C2_BOUND_AUTH_TRANSCRIPT_BYTES {
2618        return Err(C2AuthTranscriptError::TooLong {
2619            len: message.len(),
2620            max: MAX_C2_BOUND_AUTH_TRANSCRIPT_BYTES,
2621        });
2622    }
2623    Ok(message)
2624}
2625
2626fn validate_auth_capabilities(
2627    section: &'static str,
2628    capabilities: &[CapabilityId],
2629) -> Result<(), C2AuthTranscriptError> {
2630    if capabilities.len() > MAX_C2_AUTH_COMPATIBILITY_CAPABILITIES {
2631        return Err(C2AuthTranscriptError::TooManyCapabilities {
2632            section,
2633            count: capabilities.len(),
2634            max: MAX_C2_AUTH_COMPATIBILITY_CAPABILITIES,
2635        });
2636    }
2637    Ok(())
2638}
2639
2640fn encode_protocol_range(message: &mut Vec<u8>, range: ProtocolRange) {
2641    message.extend_from_slice(&range.minimum().to_le_bytes());
2642    message.extend_from_slice(&range.maximum().to_le_bytes());
2643}
2644
2645fn encode_capabilities(message: &mut Vec<u8>, capabilities: &[CapabilityId]) {
2646    message.extend_from_slice(&(capabilities.len() as u16).to_le_bytes());
2647    for capability in capabilities {
2648        encode_bounded_str(message, capability.as_str());
2649    }
2650}
2651
2652fn encode_bounded_str(message: &mut Vec<u8>, value: &str) {
2653    debug_assert!(value.len() <= u16::MAX as usize);
2654    message.extend_from_slice(&(value.len() as u16).to_le_bytes());
2655    message.extend_from_slice(value.as_bytes());
2656}
2657
2658#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2659#[serde(rename_all = "kebab-case")]
2660pub enum NodeTransportState {
2661    Online,
2662    Offline,
2663    Parked,
2664}
2665
2666#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2667#[serde(rename_all = "kebab-case")]
2668pub enum NodeFreshness {
2669    Fresh,
2670    Stale,
2671    Unavailable,
2672}
2673
2674#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2675#[serde(rename_all = "kebab-case")]
2676pub enum GapKind {
2677    IncarnationChanged,
2678    HistoryEvicted,
2679    NonContiguousEvents,
2680    CursorRegression,
2681}
2682
2683#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2684pub struct NodeGap {
2685    pub kind: GapKind,
2686    pub detected_at_unix_ms: u64,
2687    pub previous: Option<NodeCursor>,
2688    pub observed: NodeCursor,
2689}
2690
2691#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2692#[serde(rename_all = "kebab-case")]
2693pub enum C2ErrorCategory {
2694    Authentication,
2695    Identity,
2696    Protocol,
2697    Transport,
2698    Timeout,
2699    Internal,
2700}
2701
2702#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2703pub struct SanitizedError {
2704    pub category: C2ErrorCategory,
2705    pub message: String,
2706}
2707
2708#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2709pub struct SlimSession {
2710    pub instance_id: AgentInstanceId,
2711    pub generation: SessionGeneration,
2712    pub agent_id: String,
2713    pub transport: TransportKind,
2714    pub status: SlimSessionStatus,
2715    pub process_id: Option<u32>,
2716    pub terminal_size: Option<TerminalSize>,
2717    pub operation_pending: bool,
2718    pub input_pending: bool,
2719    /// The node's current screen classification for this session, carried
2720    /// on the inventory rather than only on terminal frames so a consumer
2721    /// deciding whether to hand this session work can read it without
2722    /// subscribing to frames for every session it owns. `#[serde(default)]`
2723    /// decodes an older node's silence as `Unknown`.
2724    #[serde(default)]
2725    pub screen_state: PtyScreenState,
2726}
2727
2728#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2729#[serde(rename_all = "kebab-case")]
2730pub enum SlimSessionStatus {
2731    Registered,
2732    Starting,
2733    Running,
2734    Stopping,
2735    Exited,
2736    Failed,
2737}
2738
2739impl From<&SessionStatus> for SlimSessionStatus {
2740    fn from(status: &SessionStatus) -> Self {
2741        match status {
2742            SessionStatus::Registered => Self::Registered,
2743            SessionStatus::Starting => Self::Starting,
2744            SessionStatus::Running => Self::Running,
2745            SessionStatus::Stopping => Self::Stopping,
2746            SessionStatus::Exited { .. } => Self::Exited,
2747            SessionStatus::Failed { .. } => Self::Failed,
2748        }
2749    }
2750}
2751
2752impl From<&C2SessionStatus> for SlimSessionStatus {
2753    fn from(status: &C2SessionStatus) -> Self {
2754        match status {
2755            C2SessionStatus::Registered => Self::Registered,
2756            C2SessionStatus::Starting => Self::Starting,
2757            C2SessionStatus::Running => Self::Running,
2758            C2SessionStatus::Stopping => Self::Stopping,
2759            C2SessionStatus::Exited { .. } => Self::Exited,
2760            C2SessionStatus::Failed => Self::Failed,
2761        }
2762    }
2763}
2764
2765#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2766pub struct SlimWorkspace {
2767    pub workspace_id: WorkspaceId,
2768    pub canonical_root: String,
2769    pub canonical_root_truncated: bool,
2770    pub sessions: Vec<SlimSession>,
2771    pub session_count: usize,
2772    pub sessions_truncated: bool,
2773    #[serde(default, skip_serializing_if = "Option::is_none")]
2774    pub worktree_service_mode: Option<WorktreeServiceMode>,
2775    #[serde(default, skip_serializing_if = "Option::is_none")]
2776    pub managed_worktree_profiles: Option<WorktreeProfileInventory>,
2777}
2778
2779#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2780pub struct SlimManagedSessionRecord {
2781    pub record_id: SessionRecordId,
2782    pub display_name: String,
2783    pub display_name_truncated: bool,
2784    pub provider: AgentId,
2785    pub mode: SessionMode,
2786    pub state: ManagedSessionState,
2787    pub workspace_id: WorkspaceId,
2788    pub active_session: Option<SessionAddress>,
2789    #[serde(default, skip_serializing_if = "Option::is_none")]
2790    pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
2791    #[serde(default, skip_serializing_if = "Option::is_none")]
2792    pub bundle: Option<ResolvedBundleReceipt>,
2793    pub provider_identity_present: bool,
2794    pub updated_at_unix_ms: u64,
2795}
2796
2797#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2798pub struct SlimNodeInventory {
2799    pub node_id: NodeId,
2800    pub enabled_providers: Vec<AgentId>,
2801    #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
2802    pub provider_runtime_statuses: ProviderRuntimeStatuses,
2803    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2804    pub provider_contracts: Vec<ProviderContractSupport>,
2805    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2806    pub provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
2807    pub workspaces: BTreeMap<WorkspaceId, SlimWorkspace>,
2808    pub workspace_count: usize,
2809    pub workspaces_truncated: bool,
2810    pub session_count: usize,
2811    pub sessions_truncated: bool,
2812    #[serde(default)]
2813    pub managed_sessions: Vec<SlimManagedSessionRecord>,
2814    #[serde(default)]
2815    pub managed_session_count: usize,
2816    #[serde(default)]
2817    pub managed_sessions_truncated: bool,
2818    /// Lifetime count of managed session records the node's own retention
2819    /// sweep has retired (`NodeShared::retired_records_total`, gate4agent-
2820    /// node). Additive and informational only -- unlike `managed_session_
2821    /// count`/`managed_sessions_truncated` it is not cross-checked against
2822    /// `managed_sessions` (retired records are gone from that list by
2823    /// definition, not merely paged out of it). `0` on any snapshot source
2824    /// that does not yet carry the node's own counter through.
2825    #[serde(default, skip_serializing_if = "usize_is_zero")]
2826    pub retired_count: usize,
2827    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2828    pub managed_worktrees: Vec<ManagedWorktreeLeaseSnapshot>,
2829    #[serde(default, skip_serializing_if = "usize_is_zero")]
2830    pub managed_worktree_count: usize,
2831    #[serde(default, skip_serializing_if = "bool_is_false")]
2832    pub managed_worktrees_truncated: bool,
2833    #[serde(default, skip_serializing_if = "Option::is_none")]
2834    pub launch_inventory: Option<LaunchInventory>,
2835}
2836
2837fn usize_is_zero(value: &usize) -> bool { *value == 0 }
2838
2839fn bool_is_false(value: &bool) -> bool { !*value }
2840
2841/// Orders `ManagedSessionRecord`/`C2ManagedSessionRecord` entries by liveness
2842/// before the `MAX_C2_MANAGED_SESSIONS_PER_NODE` page bound is applied, so a
2843/// page cut never evicts a live record behind stale ones that merely sort
2844/// earlier by id. `Live` sessions are the ones actively serving a run and
2845/// must never be cut. `IdentityPending` is a real, just-spawned process
2846/// still waiting on its identity observation -- exactly the window in which
2847/// the harness must still see the record on the page for credential
2848/// binding and context-source correlation -- so it ranks right behind
2849/// `Live`, ahead of `Dormant`. `Dormant` has no live process but can still
2850/// be resumed, so it ranks next. `Unavailable` is not usable at all and
2851/// ranks last, ordered by record id within each tier like before.
2852fn managed_session_liveness_rank(state: &ManagedSessionState) -> u8 {
2853    match state {
2854        ManagedSessionState::Live => 0,
2855        ManagedSessionState::IdentityPending => 1,
2856        ManagedSessionState::Dormant => 2,
2857        ManagedSessionState::Unavailable => 3,
2858    }
2859}
2860
2861/// Same liveness-first rationale as `managed_session_liveness_rank`, applied
2862/// to `gate4agent_types::SessionStatus` PTY sessions ahead of the
2863/// `MAX_C2_SESSIONS_PER_NODE` page bound: `Running` sessions rank first,
2864/// `Starting`/`Stopping` (mid-transition, still attached to a live process)
2865/// next, not-yet-started `Registered` sessions next, and terminal
2866/// `Exited`/`Failed` sessions rank last.
2867fn session_liveness_rank(status: &SessionStatus) -> u8 {
2868    match status {
2869        SessionStatus::Running => 0,
2870        SessionStatus::Starting | SessionStatus::Stopping => 1,
2871        SessionStatus::Registered => 2,
2872        SessionStatus::Exited { .. } | SessionStatus::Failed { .. } => 3,
2873    }
2874}
2875
2876/// Same as `session_liveness_rank`, for the C2-side `C2SessionStatus` mirror.
2877fn c2_session_liveness_rank(status: &C2SessionStatus) -> u8 {
2878    match status {
2879        C2SessionStatus::Running => 0,
2880        C2SessionStatus::Starting | C2SessionStatus::Stopping => 1,
2881        C2SessionStatus::Registered => 2,
2882        C2SessionStatus::Exited { .. } | C2SessionStatus::Failed => 3,
2883    }
2884}
2885
2886impl SlimNodeInventory {
2887    pub fn from_snapshot(snapshot: &NodeSnapshot) -> Self {
2888        let mut providers = snapshot.enabled_providers.clone();
2889        providers.sort();
2890        providers.dedup();
2891        let workspace_count = snapshot.workspaces.len();
2892        let session_count = snapshot.workspaces.iter().map(|workspace| workspace.sessions.len()).sum();
2893        let mut remaining_sessions = MAX_C2_SESSIONS_PER_NODE;
2894        let mut workspaces = BTreeMap::new();
2895        let mut ordered = snapshot.workspaces.iter().collect::<Vec<_>>();
2896        ordered.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id));
2897        for workspace in ordered.into_iter().take(MAX_C2_WORKSPACES_PER_NODE) {
2898            let mut sessions = workspace.sessions.iter().collect::<Vec<_>>();
2899            sessions.sort_by(|left, right| {
2900                session_liveness_rank(&left.status)
2901                    .cmp(&session_liveness_rank(&right.status))
2902                    .then_with(|| left.instance_id.cmp(&right.instance_id))
2903                    .then_with(|| left.generation.cmp(&right.generation))
2904            });
2905            let take = remaining_sessions.min(sessions.len());
2906            let slim_sessions = sessions.into_iter().take(take).map(|session| SlimSession {
2907                instance_id: session.instance_id,
2908                generation: session.generation,
2909                agent_id: session.agent_id.as_str().to_owned(),
2910                transport: session.transport,
2911                status: SlimSessionStatus::from(&session.status),
2912                process_id: session.process_id,
2913                terminal_size: session.terminal_size,
2914                operation_pending: session.pending_operation.is_some(),
2915                input_pending: session.pending_input.is_some(),
2916                screen_state: session.screen_state.clone(),
2917            }).collect();
2918            remaining_sessions -= take;
2919            let display_root = sanitize_host_path_display(&workspace.canonical_root);
2920            let (canonical_root, canonical_root_truncated) =
2921                truncate_utf8(&display_root, MAX_C2_ROOT_BYTES);
2922            workspaces.insert(workspace.workspace_id.clone(), SlimWorkspace {
2923                workspace_id: workspace.workspace_id.clone(),
2924                canonical_root,
2925                canonical_root_truncated,
2926                sessions: slim_sessions,
2927                session_count: workspace.sessions.len(),
2928                sessions_truncated: workspace.sessions.len() > take,
2929                worktree_service_mode: workspace.worktree_service_mode,
2930                managed_worktree_profiles: workspace.managed_worktree_profiles.clone(),
2931            });
2932        }
2933        let included_session_count = workspaces
2934            .values()
2935            .map(|workspace| workspace.sessions.len())
2936            .sum::<usize>();
2937        let managed_session_count = snapshot.session_records.len();
2938        let mut ordered_records = snapshot.session_records.iter().collect::<Vec<_>>();
2939        ordered_records.sort_by(|left, right| {
2940            managed_session_liveness_rank(&left.state)
2941                .cmp(&managed_session_liveness_rank(&right.state))
2942                .then_with(|| left.record_id.cmp(&right.record_id))
2943        });
2944        let managed_sessions = ordered_records
2945            .into_iter()
2946            .take(MAX_C2_MANAGED_SESSIONS_PER_NODE)
2947            .map(SlimManagedSessionRecord::from)
2948            .collect::<Vec<_>>();
2949        let managed_worktree_count = snapshot.managed_worktrees.len();
2950        let mut managed_worktrees = snapshot.managed_worktrees.clone();
2951        managed_worktrees.sort_by(|left, right| left.lease_id.cmp(&right.lease_id));
2952        managed_worktrees.truncate(MAX_C2_MANAGED_WORKTREES_PER_NODE);
2953        Self {
2954            node_id: snapshot.node_id.clone(),
2955            enabled_providers: providers,
2956            provider_runtime_statuses: snapshot.provider_runtime_statuses.clone(),
2957            provider_contracts: Vec::new(),
2958            provider_adapter_contracts: Vec::new(),
2959            workspaces,
2960            workspace_count,
2961            workspaces_truncated: workspace_count > MAX_C2_WORKSPACES_PER_NODE,
2962            session_count,
2963            sessions_truncated: included_session_count < session_count,
2964            managed_sessions_truncated: managed_sessions.len() < managed_session_count,
2965            managed_sessions,
2966            managed_session_count,
2967            // Neither `NodeSnapshot` nor `C2NodeSnapshot` carries the
2968            // node's own `retired_records_total` atomic through yet -- see
2969            // this field's own doc comment. `0` here is the honest value
2970            // for what this projection can see today, not a placeholder
2971            // masking a real number.
2972            retired_count: 0,
2973            managed_worktrees_truncated:
2974                managed_worktrees.len() < managed_worktree_count,
2975            managed_worktrees,
2976            managed_worktree_count,
2977            launch_inventory: snapshot.launch_inventory.clone(),
2978        }
2979    }
2980
2981    pub fn from_c2_snapshot(snapshot: &C2NodeSnapshot) -> Self {
2982        let mut providers = snapshot.enabled_providers.clone();
2983        providers.sort();
2984        providers.dedup();
2985        let workspace_count = snapshot.workspaces.len();
2986        let session_count = snapshot.workspaces.iter().map(|workspace| workspace.sessions.len()).sum();
2987        let mut remaining_sessions = MAX_C2_SESSIONS_PER_NODE;
2988        let mut workspaces = BTreeMap::new();
2989        let mut ordered = snapshot.workspaces.iter().collect::<Vec<_>>();
2990        ordered.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id));
2991        for workspace in ordered.into_iter().take(MAX_C2_WORKSPACES_PER_NODE) {
2992            let mut sessions = workspace.sessions.iter().collect::<Vec<_>>();
2993            sessions.sort_by(|left, right| {
2994                c2_session_liveness_rank(&left.status)
2995                    .cmp(&c2_session_liveness_rank(&right.status))
2996                    .then_with(|| left.instance_id.cmp(&right.instance_id))
2997                    .then_with(|| left.generation.cmp(&right.generation))
2998            });
2999            let take = remaining_sessions.min(sessions.len());
3000            let slim_sessions = sessions.into_iter().take(take).map(|session| SlimSession {
3001                instance_id: session.instance_id,
3002                generation: session.generation,
3003                agent_id: session.agent_id.as_str().to_owned(),
3004                transport: session.transport,
3005                status: SlimSessionStatus::from(&session.status),
3006                process_id: session.process_id,
3007                terminal_size: session.terminal_size,
3008                operation_pending: session.pending_operation.is_some(),
3009                input_pending: session.pending_input.is_some(),
3010                screen_state: session.screen_state.clone(),
3011            }).collect();
3012            remaining_sessions -= take;
3013            let display_root = sanitize_host_path_display(&workspace.canonical_root);
3014            let (canonical_root, canonical_root_truncated) =
3015                truncate_utf8(&display_root, MAX_C2_ROOT_BYTES);
3016            workspaces.insert(workspace.workspace_id.clone(), SlimWorkspace {
3017                workspace_id: workspace.workspace_id.clone(),
3018                canonical_root,
3019                canonical_root_truncated,
3020                sessions: slim_sessions,
3021                session_count: workspace.sessions.len(),
3022                sessions_truncated: workspace.sessions.len() > take,
3023                worktree_service_mode: workspace.worktree_service_mode,
3024                managed_worktree_profiles: workspace.managed_worktree_profiles.clone(),
3025            });
3026        }
3027        let included_session_count = workspaces.values()
3028            .map(|workspace| workspace.sessions.len())
3029            .sum::<usize>();
3030        let managed_session_count = snapshot.session_records.len();
3031        let mut ordered_records = snapshot.session_records.iter().collect::<Vec<_>>();
3032        ordered_records.sort_by(|left, right| {
3033            managed_session_liveness_rank(&left.state)
3034                .cmp(&managed_session_liveness_rank(&right.state))
3035                .then_with(|| left.record_id.cmp(&right.record_id))
3036        });
3037        let managed_sessions = ordered_records.into_iter()
3038            .take(MAX_C2_MANAGED_SESSIONS_PER_NODE)
3039            .map(SlimManagedSessionRecord::from)
3040            .collect::<Vec<_>>();
3041        let managed_worktree_count = snapshot.managed_worktrees.len();
3042        let mut managed_worktrees = snapshot.managed_worktrees.clone();
3043        managed_worktrees.sort_by(|left, right| left.lease_id.cmp(&right.lease_id));
3044        managed_worktrees.truncate(MAX_C2_MANAGED_WORKTREES_PER_NODE);
3045        Self {
3046            node_id: snapshot.node_id.clone(),
3047            enabled_providers: providers,
3048            provider_runtime_statuses: snapshot.provider_runtime_statuses.clone(),
3049            provider_contracts: Vec::new(),
3050            provider_adapter_contracts: Vec::new(),
3051            workspaces,
3052            workspace_count,
3053            workspaces_truncated: workspace_count > MAX_C2_WORKSPACES_PER_NODE,
3054            session_count,
3055            sessions_truncated: included_session_count < session_count,
3056            managed_sessions_truncated: managed_sessions.len() < managed_session_count,
3057            managed_sessions,
3058            managed_session_count,
3059            // Neither `NodeSnapshot` nor `C2NodeSnapshot` carries the
3060            // node's own `retired_records_total` atomic through yet -- see
3061            // this field's own doc comment. `0` here is the honest value
3062            // for what this projection can see today, not a placeholder
3063            // masking a real number.
3064            retired_count: 0,
3065            managed_worktrees_truncated:
3066                managed_worktrees.len() < managed_worktree_count,
3067            managed_worktrees,
3068            managed_worktree_count,
3069            launch_inventory: snapshot.launch_inventory.clone(),
3070        }
3071    }
3072}
3073
3074impl From<&ManagedSessionRecord> for SlimManagedSessionRecord {
3075    fn from(record: &ManagedSessionRecord) -> Self {
3076        let (display_name, display_name_truncated) =
3077            truncate_utf8(&record.display_name, MAX_C2_SESSION_DISPLAY_NAME_BYTES);
3078        Self {
3079            // Legacy Slim inventory deliberately strips context_id and context receipts.
3080            record_id: record.record_id.clone(),
3081            display_name,
3082            display_name_truncated,
3083            provider: record.provider.clone(),
3084            mode: record.mode,
3085            state: record.state,
3086            workspace_id: record.workspace_id.clone(),
3087            active_session: record.active_session.clone(),
3088            environment_profile: record.environment_profile.clone(),
3089            bundle: record.bundle.clone(),
3090            provider_identity_present: record.provider_session.is_some(),
3091            updated_at_unix_ms: record.updated_at_unix_ms,
3092        }
3093    }
3094}
3095
3096impl From<&C2ManagedSessionRecord> for SlimManagedSessionRecord {
3097    fn from(record: &C2ManagedSessionRecord) -> Self {
3098        let (display_name, display_name_truncated) =
3099            truncate_utf8(&record.display_name, MAX_C2_SESSION_DISPLAY_NAME_BYTES);
3100        Self {
3101            // Legacy Slim inventory deliberately strips context_id and context receipts.
3102            record_id: record.record_id.clone(),
3103            display_name,
3104            display_name_truncated,
3105            provider: record.provider.clone(),
3106            mode: record.mode,
3107            state: record.state,
3108            workspace_id: record.workspace_id.clone(),
3109            active_session: record.active_session.clone(),
3110            environment_profile: record.environment_profile.clone(),
3111            bundle: record.bundle.clone(),
3112            provider_identity_present: record.provider_identity_present,
3113            updated_at_unix_ms: record.updated_at_unix_ms,
3114        }
3115    }
3116}
3117
3118fn truncate_utf8(value: &str, max_bytes: usize) -> (String, bool) {
3119    if value.len() <= max_bytes {
3120        return (value.to_owned(), false);
3121    }
3122    let mut end = max_bytes;
3123    while !value.is_char_boundary(end) {
3124        end -= 1;
3125    }
3126    (value[..end].to_owned(), true)
3127}
3128
3129fn sanitize_host_path_display(path: &OpaqueHostPath) -> String {
3130    let mut sanitized = String::new();
3131    for ch in path.display_text().chars() {
3132        if ch.is_control() {
3133            sanitized.extend(ch.escape_default());
3134        } else {
3135            sanitized.push(ch);
3136        }
3137    }
3138    sanitized
3139}
3140
3141#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3142pub struct ObservedNode {
3143    pub endpoint: String,
3144    pub transport_label: String,
3145    pub transport: NodeTransportState,
3146    pub freshness: NodeFreshness,
3147    pub cursor: Option<NodeCursor>,
3148    pub inventory: Option<SlimNodeInventory>,
3149    pub last_attempt_unix_ms: Option<u64>,
3150    pub last_success_unix_ms: Option<u64>,
3151    pub consecutive_failures: u32,
3152    pub last_error: Option<SanitizedError>,
3153    pub gaps: Vec<NodeGap>,
3154    pub gaps_truncated: u64,
3155}
3156
3157#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3158pub struct HealthResponse {
3159    pub ok: bool,
3160    pub service: String,
3161    pub api_version: u16,
3162    pub pid: u32,
3163    pub version: String,
3164}
3165
3166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3167pub struct ReadyResponse {
3168    pub ready: bool,
3169    pub api_version: u16,
3170    pub configured_nodes: usize,
3171    pub attempted_nodes: usize,
3172    pub online_nodes: usize,
3173    pub offline_nodes: usize,
3174    pub parked_nodes: usize,
3175}
3176
3177#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3178pub struct StatusResponse {
3179    pub api_version: u16,
3180    pub ready: bool,
3181    pub observed_at_unix_ms: u64,
3182    pub nodes: BTreeMap<NodeId, ObservedNode>,
3183}
3184
3185#[cfg(test)]
3186mod tests {
3187    use super::*;
3188    use gate4agent_node_protocol::{
3189        GitSnapshot, GitStatusEntry, GitWorktreeSnapshot, NodeFailureCode, NodeSnapshot,
3190        WorkspaceEntry, WorkspaceEntryKind, WorkspaceInspection, WorkspaceSnapshot,
3191    };
3192    use gate4agent_types::{
3193        AdapterBinding, AdapterFamily, AdapterId, AdapterVerification, AgentId, AgentInstanceId,
3194        CapabilitySnapshot, ControlEvent, ControlEventKind, ForegroundSnapshot, HistorySnapshot,
3195        OperationId, PreparedInputKind, ProviderEvent, ProviderInteractionOutcome,
3196        ProviderSessionIdentity, ProviderSessionKey, ProviderSnapshot, ProviderSource,
3197        PtyScreenState, ResumeSessionSummary, ResumeSnapshot, SessionGeneration, SessionSnapshot,
3198        SessionStatus, TerminalSize, TransportKind,
3199    };
3200
3201    #[test]
3202    fn c2_harness_mcp_projection_is_value_exact_and_sanitized() {
3203        let reservation_id = HarnessMcpReservationId::new(format!(
3204            "hmcpres_{}", "a".repeat(24),
3205        )).unwrap();
3206        let activation_digest = HarnessMcpActivationDigest::new(format!(
3207            "sha256:{}", "b".repeat(64),
3208        )).unwrap();
3209        let call_id = HarnessMcpCallId::new(format!("hmcpcall_{}", "c".repeat(24))).unwrap();
3210        let session = SessionAddress {
3211            workspace_id: WorkspaceId::new("primary").unwrap(),
3212            session: gate4agent_node_protocol::SessionKey {
3213                instance_id: AgentInstanceId(7),
3214                generation: SessionGeneration(1),
3215            },
3216        };
3217        let source = NodeEvent::HarnessMcpReadCall {
3218            reservation_id: reservation_id.clone(),
3219            activation_digest: activation_digest.clone(),
3220            record_id: SessionRecordId::new("record-a").unwrap(),
3221            session: session.clone(),
3222            call_id: call_id.clone(),
3223            request: HarnessMcpOpaquePayloadV1 {
3224                content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
3225                body: br#"{"kind":"context-get"}"#.to_vec(),
3226            },
3227            deadline_unix_ms: 4_000,
3228        };
3229        let projected = C2NodeEvent::from(&source);
3230        assert!(projected.requires_harness_mcp_proxy_capability());
3231        assert!(projected.harness_mcp_contract_is_valid_at(1_000));
3232        let json = serde_json::to_string(&projected).unwrap();
3233        assert!(!json.contains("g4ah3_"));
3234        assert!(!json.contains("endpoint"));
3235        assert!(!json.contains("path"));
3236        assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3237
3238        let response = NodeResponse::ReplyChunkAccepted {
3239            reservation_id,
3240            activation_digest,
3241            record_id: SessionRecordId::new("record-a").unwrap(),
3242            session,
3243            call_id,
3244            next_offset: 2,
3245            completed: true,
3246        };
3247        let projected_response = C2NodeResponse::from(&response);
3248        assert!(projected_response.requires_harness_mcp_proxy_capability());
3249        assert_eq!(
3250            serde_json::from_value::<C2NodeResponse>(
3251                serde_json::to_value(&projected_response).unwrap(),
3252            ).unwrap(),
3253            projected_response,
3254        );
3255
3256        let failure = C2NodeFailure::from(&NodeFailure {
3257            code: NodeFailureCode::ChunkOutOfOrder,
3258            message: "secret raw detail".to_owned(),
3259        });
3260        assert_eq!(failure.message, "harness MCP reply chunk out of order");
3261        assert!(!failure.message.contains("secret"));
3262        assert!(failure.requires_harness_mcp_proxy_capability());
3263        assert_eq!(C2_HARNESS_MCP_READ_PROXY_CAPABILITY, "harness-mcp-read-proxy-v1");
3264    }
3265
3266    fn host_path(value: impl Into<String>) -> OpaqueHostPath {
3267        OpaqueHostPath::utf8(value.into()).unwrap()
3268    }
3269
3270    fn repository_path(value: impl Into<String>) -> RepositoryPath {
3271        RepositoryPath::utf8(value.into()).unwrap()
3272    }
3273
3274    fn provider(value: &str) -> AgentId {
3275        AgentId::new(value).unwrap()
3276    }
3277
3278    fn fixture_session() -> SessionSnapshot {
3279        SessionSnapshot {
3280            instance_id: AgentInstanceId(7),
3281            agent_id: AgentId::new("codex").unwrap(),
3282            transport: TransportKind::Pty,
3283            generation: SessionGeneration(2),
3284            status: SessionStatus::Running,
3285            pending_operation: Some(OperationId(9)),
3286            pending_input: Some(PreparedInputKind::TerminalText),
3287            process_id: Some(1234),
3288            terminal_size: Some(TerminalSize { rows: 40, columns: 120 }),
3289            terminal_frame: None,
3290            terminal_stale: None,
3291            session_options: None,
3292            capabilities: CapabilitySnapshot::default(),
3293            history: HistorySnapshot::default(),
3294            resume: ResumeSnapshot::default(),
3295            foreground: ForegroundSnapshot::default(),
3296            provider: ProviderSnapshot::default(),
3297            screen_state: PtyScreenState::default(),
3298        }
3299    }
3300
3301    fn fixture_agent_progress() -> SessionAgentProgress {
3302        SessionAgentProgress {
3303            address: SessionAddress {
3304                workspace_id: WorkspaceId::new("primary").unwrap(),
3305                session: gate4agent_node_protocol::SessionKey {
3306                    instance_id: AgentInstanceId(7),
3307                    generation: SessionGeneration(2),
3308                },
3309            },
3310            progress: AgentProgressV1 {
3311                provider_sequence: 19,
3312                activity: ProviderActivity::Working,
3313                completed_turns: 3,
3314                usage: Some(AgentProgressUsageV1 {
3315                    input_tokens: 101,
3316                    output_tokens: 37,
3317                    cache_read_tokens: 11,
3318                    cache_write_tokens: 5,
3319                    reasoning_tokens: 13,
3320                }),
3321                current: AgentProgressCurrentV1::Working,
3322                // Capitalized because `validate_agent_progress_tool_label`
3323                // accepts a fixed capitalized class vocabulary. Lowercase
3324                // decodes as a label outside it, so this fixture stood for
3325                // an invalid value rather than the safe class it meant.
3326                active_tool_labels: vec!["Shell".to_owned()],
3327                active_tool_count: 1,
3328                attention: None,
3329                subagent_count: 2,
3330                last_event_kind: Some(AgentProgressEventKindV1::ToolStarted),
3331                gap_count: 0,
3332                stale: false,
3333                truncated: false,
3334            },
3335        }
3336    }
3337
3338    #[test]
3339    fn c2_agent_progress_snapshot_roundtrip_is_exact_and_private() {
3340        assert_eq!(
3341            C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3342            "agent-progress-snapshot-v1",
3343        );
3344        assert_eq!(
3345            C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3346            gate4agent_node_protocol::NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3347        );
3348        let progress = fixture_agent_progress();
3349        let snapshot = C2NodeSnapshot {
3350            node_id: NodeId::new("node-a").unwrap(),
3351            enabled_providers: vec![provider("codex")],
3352            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
3353            workspaces: Vec::new(),
3354            session_records: Vec::new(),
3355            agent_progress: vec![progress.clone()],
3356            managed_worktrees: Vec::new(),
3357            launch_inventory: None,
3358        };
3359        let json = serde_json::to_string(&snapshot).unwrap();
3360        let decoded = serde_json::from_str::<C2NodeSnapshot>(&json).unwrap();
3361        assert_eq!(decoded.agent_progress, vec![progress]);
3362        for forbidden in [
3363            "prompt", "arguments", "contents", "transcript", "provider_session",
3364            "canonical_root", "executable",
3365        ] {
3366            assert!(!json.contains(forbidden), "agent progress leaked {forbidden}");
3367        }
3368
3369        let mut value = serde_json::to_value(snapshot).unwrap();
3370        value["agent_progress"][0]["progress"]["prompt"] =
3371            serde_json::json!("private prompt");
3372        assert!(serde_json::from_value::<C2NodeSnapshot>(value).is_err());
3373
3374        let empty = serde_json::json!({
3375            "node_id": "node-a",
3376            "enabled_providers": [],
3377            "workspaces": [],
3378            "session_records": [],
3379            "managed_worktrees": [],
3380            "launch_inventory": null
3381        });
3382        let empty = serde_json::from_value::<C2NodeSnapshot>(empty).unwrap();
3383        assert!(empty.agent_progress.is_empty());
3384        assert!(serde_json::to_value(empty).unwrap().get("agent_progress").is_none());
3385    }
3386
3387    #[test]
3388    fn c2_workspace_event_does_not_synthesize_agent_progress() {
3389        let event = C2NodeEvent::from(&NodeEvent::WorkspaceAdded {
3390            workspace: WorkspaceSnapshot {
3391                workspace_id: WorkspaceId::new("primary").unwrap(),
3392                canonical_root: host_path(r"C:\private\workspace"),
3393                sessions: vec![fixture_session()],
3394                worktree_service_mode: None,
3395                managed_worktree_profiles: None,
3396            },
3397        });
3398        let json = serde_json::to_string(&event).unwrap();
3399        assert!(!json.contains("agent_progress"));
3400        assert!(!json.contains("provider_sequence"));
3401    }
3402
3403    fn private_session_record() -> ManagedSessionRecord {
3404        ManagedSessionRecord {
3405            record_id: SessionRecordId::new("session-private").unwrap(),
3406            display_name: "release shepherd".to_owned(),
3407            provider: provider("codex"),
3408            mode: SessionMode::Pty,
3409            state: ManagedSessionState::Live,
3410            workspace_id: WorkspaceId::new("primary").unwrap(),
3411            canonical_root: host_path(r"C:\private\canonical-root"),
3412            provider_session: Some(ProviderSessionIdentity {
3413                key: ProviderSessionKey::SessionId,
3414                id: "private-provider-session-id".to_owned(),
3415                transcript_path: Some(r"C:\private\transcript-secret.jsonl".to_owned()),
3416            }),
3417            active_session: Some(SessionAddress {
3418                workspace_id: WorkspaceId::new("primary").unwrap(),
3419                session: gate4agent_node_protocol::SessionKey {
3420                    instance_id: AgentInstanceId(41),
3421                    generation: SessionGeneration(3),
3422                },
3423            }),
3424            environment_profile: Some(ResolvedEnvironmentProfileReceipt {
3425                profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
3426                profile_revision: SpawnEnvironmentProfileRevision::new(
3427                    "local-default.2026-08",
3428                )
3429                .unwrap(),
3430                network_allowlist: None,
3431                browser_profile_id: None,
3432            }),
3433            bundle: None,
3434            context_id: None,
3435            context: None,
3436            exported_context: None,
3437            task_binding: None,
3438            created_at_unix_ms: 10,
3439            updated_at_unix_ms: 20,
3440            last_error: Some("private-error-with-secret-token".to_owned()),
3441        }
3442    }
3443
3444    fn context_pack_receipt() -> ResolvedContextPackReceipt {
3445        ResolvedContextPackReceipt {
3446            id: SpawnContextId::new("context-review-7").unwrap(),
3447            digest: SpawnContextDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
3448            lineage: ContextPackLineageReceipt {
3449                source_node_id: NodeId::new("node-source").unwrap(),
3450                source_session: SessionAddress {
3451                    workspace_id: WorkspaceId::new("source-workspace").unwrap(),
3452                    session: gate4agent_node_protocol::SessionKey {
3453                        instance_id: AgentInstanceId(17),
3454                        generation: SessionGeneration(4),
3455                    },
3456                },
3457                source_provider: provider("codex"),
3458            },
3459            source_message_count: 9,
3460            retained_message_count: 7,
3461            byte_len: 4096,
3462            truncated: true,
3463        }
3464    }
3465
3466    fn assert_private_record_fields_absent(json: &str) {
3467        assert!(!json.contains("canonical_root"));
3468        assert!(!json.contains("provider_session"));
3469        assert!(!json.contains("private-provider-session-id"));
3470        assert!(!json.contains("transcript_path"));
3471        assert!(!json.contains("transcript-secret.jsonl"));
3472        assert!(!json.contains("last_error"));
3473        assert!(!json.contains("private-error-with-secret-token"));
3474    }
3475
3476    #[test]
3477    fn c2_task_binding_projection_is_exact_safe_and_legacy_slim_omits_it() {
3478        let mut source = private_session_record();
3479        source.task_binding = Some(SessionTaskBindingV1 {
3480            revision: 3,
3481            task_id: Some(TaskId::from_nonce([3; 12])),
3482            changed_at_unix_ms: 15,
3483        });
3484        let projected = C2ManagedSessionRecord::from(&source);
3485        assert_eq!(projected.task_binding, source.task_binding);
3486        assert!(projected.task_binding_is_valid());
3487        let slim = serde_json::to_value(SlimManagedSessionRecord::from(&projected)).unwrap();
3488        assert!(slim.get("task_binding").is_none());
3489
3490        source.task_binding.as_mut().unwrap().changed_at_unix_ms = 21;
3491        assert!(C2ManagedSessionRecord::from(&source).task_binding.is_none());
3492
3493        let mut invalid = serde_json::to_value(projected).unwrap();
3494        invalid["task_binding"]["changed_at_unix_ms"] = serde_json::json!(9);
3495        assert!(serde_json::from_value::<C2ManagedSessionRecord>(invalid).is_err());
3496    }
3497
3498    fn routed_response(response: NodeResponse) -> RoutedNodeResponse {
3499        RoutedNodeResponse {
3500            node_id: NodeId::new("node-a").unwrap(),
3501            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3502            response: Ok(C2NodeResponse::from(&response)),
3503        }
3504    }
3505
3506    fn control_node_event(event: ControlEventKind) -> NodeEvent {
3507        NodeEvent::Control {
3508            address: SessionAddress {
3509                workspace_id: WorkspaceId::new("primary").unwrap(),
3510                session: gate4agent_node_protocol::SessionKey {
3511                    instance_id: AgentInstanceId(41),
3512                    generation: SessionGeneration(3),
3513                },
3514            },
3515            event: ControlEvent {
3516                sequence: 12,
3517                command_id: None,
3518                instance_id: AgentInstanceId(41),
3519                generation: SessionGeneration(3),
3520                event,
3521            },
3522        }
3523    }
3524
3525    #[test]
3526    fn history_summary_event_projects_exactly_and_carries_counts_only() {
3527        let summary = gate4agent_node_protocol::SessionHistorySummaryV1 {
3528            message_count: 12,
3529            message_count_exact: true,
3530            completed_turn_count: Some(5),
3531            total_tokens: Some(4_000),
3532            modified_at_unix_ms: Some(1_786_000_000_000),
3533        };
3534        let source = NodeEvent::SessionRecordHistorySummarized {
3535            record_id: SessionRecordId::new("record-a").unwrap(),
3536            summary,
3537        };
3538        let projected = C2NodeEvent::from(&source);
3539        assert_eq!(
3540            projected,
3541            C2NodeEvent::SessionRecordHistorySummarized {
3542                record_id: SessionRecordId::new("record-a").unwrap(),
3543                summary,
3544            },
3545        );
3546        assert_eq!(projected, C2NodeEvent::from_node_event_with_control_detail(&source));
3547        let json = serde_json::to_string(&projected).unwrap();
3548        assert_eq!(
3549            json,
3550            r#"{"kind":"session-record-history-summarized","record_id":"record-a","summary":{"message_count":12,"message_count_exact":true,"completed_turn_count":5,"total_tokens":4000,"modified_at_unix_ms":1786000000000}}"#,
3551        );
3552        assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3553    }
3554
3555    fn tool_started_event() -> ControlEventKind {
3556        ControlEventKind::ProviderEvent {
3557            sequence: 19,
3558            source: provider_source(),
3559            source_sequence: 7,
3560            event: ProviderEvent::ToolStarted {
3561                id: "toolu_1".to_owned(),
3562                name: "Bash".to_owned(),
3563                input_json: r#"{"command":"cat private.txt"}"#.to_owned(),
3564                agent_id: None,
3565            },
3566        }
3567    }
3568
3569    #[test]
3570    fn control_detail_is_absent_by_default_and_sanitized_when_requested() {
3571        let source = control_node_event(tool_started_event());
3572
3573        let default = C2NodeEvent::from(&source);
3574        assert!(!default.requires_control_detail_capability());
3575        assert!(!serde_json::to_string(&default).unwrap().contains("detail"));
3576
3577        let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3578        assert!(detailed.requires_control_detail_capability());
3579        let json = serde_json::to_string(&detailed).unwrap();
3580        assert!(json.contains("toolu_1"));
3581        assert!(json.contains("Bash"));
3582        assert!(!json.contains("private.txt"));
3583        assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), detailed);
3584
3585        let mut stripped = detailed;
3586        stripped.strip_control_detail();
3587        assert_eq!(stripped, default);
3588    }
3589
3590    #[test]
3591    fn content_only_control_events_carry_their_kind_but_never_their_content() {
3592        let source = control_node_event(ControlEventKind::ProviderEvent {
3593            sequence: 20,
3594            source: provider_source(),
3595            source_sequence: 8,
3596            event: ProviderEvent::Text { text: "private words".to_owned(), is_delta: true },
3597        });
3598        let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3599        assert!(detailed.requires_control_detail_capability());
3600        assert!(!serde_json::to_string(&detailed).unwrap().contains("private words"));
3601    }
3602
3603    #[test]
3604    fn control_plane_bookkeeping_has_no_detail_even_when_requested() {
3605        let source = control_node_event(ControlEventKind::Registered);
3606        let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3607        assert!(!detailed.requires_control_detail_capability());
3608        assert_eq!(detailed, C2NodeEvent::from(&source));
3609    }
3610
3611    #[test]
3612    fn resync_control_detail_follows_the_same_rule_and_strips_to_the_default() {
3613        let events = vec![
3614            gate4agent_node_protocol::NodeEventEnvelope {
3615                sequence: 3,
3616                event: control_node_event(tool_started_event()),
3617            },
3618            gate4agent_node_protocol::NodeEventEnvelope {
3619                sequence: 4,
3620                event: control_node_event(ControlEventKind::Removed),
3621            },
3622        ];
3623        let snapshot = NodeSnapshot {
3624            node_id: NodeId::new("node-a").unwrap(),
3625            enabled_providers: Vec::new(),
3626            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
3627            workspaces: Vec::new(),
3628            session_records: Vec::new(),
3629            managed_worktrees: Vec::new(),
3630            launch_inventory: None,
3631            agent_progress: Vec::new(),
3632        };
3633        let source = NodeResponse::Resync {
3634            event_sequence: 4,
3635            oldest_available_sequence: 1,
3636            snapshot,
3637            events,
3638        };
3639        let default = C2NodeResponse::from(&source);
3640        assert!(!default.requires_control_detail_capability());
3641        let mut detailed = C2NodeResponse::from_node_response_with_control_detail(&source);
3642        assert!(detailed.requires_control_detail_capability());
3643        detailed.strip_control_detail();
3644        assert_eq!(detailed, default);
3645    }
3646
3647    fn routed_control_event(event: ControlEventKind) -> RoutedNodeEvent {
3648        let address = SessionAddress {
3649            workspace_id: WorkspaceId::new("primary").unwrap(),
3650            session: gate4agent_node_protocol::SessionKey {
3651                instance_id: AgentInstanceId(41),
3652                generation: SessionGeneration(3),
3653            },
3654        };
3655        RoutedNodeEvent {
3656            node_id: NodeId::new("node-a").unwrap(),
3657            cursor: NodeCursor {
3658                incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
3659                sequence: 9,
3660            },
3661            event: C2NodeEvent::from(&NodeEvent::Control {
3662                address,
3663                event: ControlEvent {
3664                    sequence: 12,
3665                    command_id: None,
3666                    instance_id: AgentInstanceId(41),
3667                    generation: SessionGeneration(3),
3668                    event,
3669                },
3670            }),
3671        }
3672    }
3673
3674    fn provider_source() -> ProviderSource {
3675        ProviderSource {
3676            family: AdapterFamily::PtySemantic,
3677            binding: AdapterBinding::new(
3678                AdapterId::new("codex").unwrap(),
3679                "fixture/v1",
3680                AdapterVerification::SyntheticFixture,
3681            ).unwrap(),
3682        }
3683    }
3684
3685    fn provider_contract_manifest() -> (
3686        Vec<ProviderContractSupport>,
3687        Vec<ProviderAdapterContractSupport>,
3688    ) {
3689        (
3690            vec![ProviderContractSupport {
3691                provider: provider("codex"),
3692                revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
3693            }],
3694            vec![ProviderAdapterContractSupport {
3695                provider: provider("codex"),
3696                family: AdapterFamily::PtySemantic,
3697                adapter_id: AdapterId::new("codex").unwrap(),
3698                revision: AdapterContractRevision::new("pty-semantic.2026-08").unwrap(),
3699            }],
3700        )
3701    }
3702
3703    fn c2_compatibility_support(
3704        capabilities: Vec<CapabilityId>,
3705    ) -> C2ControlCompatibilitySupport {
3706        C2ControlCompatibilitySupport {
3707            build_stamp: BUILD_STAMP.to_owned(),
3708            capabilities,
3709            host: HostDescriptor {
3710                operating_system: OperatingSystemId::new("darwin").unwrap(),
3711                architecture: ArchitectureId::new("aarch64").unwrap(),
3712            },
3713            path_semantics: PathSemantics {
3714                style: PathStyle::Posix,
3715                encoding: PathEncoding::Utf8,
3716            },
3717        }
3718    }
3719
3720    /// Pins the SHAPE of the legacy hello -- which keys, in what order,
3721    /// and that `compatibility` is absent rather than serialized as
3722    /// `null` -- against a decoder that predates negotiation and would
3723    /// reject an unknown key.
3724    ///
3725    /// The stamp is interpolated from `BUILD_STAMP` rather than frozen,
3726    /// because freezing it could not survive its own design:
3727    /// `C2ClientHello::new` fills that field FROM the constant, and the
3728    /// constant is recomputed from the working tree on every build, so a
3729    /// frozen copy would go red on the very next unrelated source edit
3730    /// anywhere in the tree. The key set and their order are what must not
3731    /// drift, and those are still pinned literally.
3732    #[test]
3733    fn the_legacy_client_hello_json_carries_exactly_these_keys_in_this_order() {
3734        let hello = C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]);
3735        let json = serde_json::to_string(&hello).unwrap();
3736        let owned = format!(
3737            concat!(
3738                r#"{{"build_stamp":"{}","client_nonce":["#,
3739                "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,",
3740                "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}}"
3741            ),
3742            BUILD_STAMP,
3743        );
3744        let expected = owned.as_str();
3745
3746        assert_eq!(json, expected);
3747        // Stated separately so it cannot read as incidental to the string
3748        // above: to a strict decoder an absent key and a null one are
3749        // different messages.
3750        assert!(!json.contains("compatibility"));
3751        assert_eq!(
3752            serde_json::from_str::<C2ClientHello>(expected).unwrap(),
3753            hello,
3754        );
3755    }
3756
3757    /// `BUILD_STAMP` as the bound-auth transcript encodes it: a
3758    /// little-endian u16 byte-length prefix followed by its ASCII bytes,
3759    /// rendered as hex.
3760    ///
3761    /// The transcript tests below still pin their entire byte layout
3762    /// literally -- domain tag, direction byte, both nonces, the offer and
3763    /// selection blocks with their length prefixes, the host descriptor --
3764    /// and interpolate only this. That split is deliberate: reorder a
3765    /// field, drop a length prefix, or change an encoding and they still
3766    /// fail; edit any file in the tree and they do not, because the
3767    /// transcript derives its stamp from the same constant the expectation
3768    /// does. Freezing the stamp alongside the layout is what would leave
3769    /// these permanently red.
3770    fn build_stamp_hex() -> String {
3771        let mut bytes = Vec::with_capacity(2 + BUILD_STAMP.len());
3772        bytes.extend_from_slice(&(BUILD_STAMP.len() as u16).to_le_bytes());
3773        bytes.extend_from_slice(BUILD_STAMP.as_bytes());
3774        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
3775    }
3776
3777    #[test]
3778    fn c2_compatibility_legacy_server_json_is_byte_equivalent() {
3779        #[derive(Serialize)]
3780        struct LegacyChallenge {
3781            build_stamp: String,
3782            server_nonce: [u8; C2_AUTH_NONCE_BYTES],
3783            server_proof: [u8; C2_AUTH_PROOF_BYTES],
3784        }
3785
3786        #[derive(Serialize)]
3787        struct LegacyHello<'a> {
3788            build_stamp: String,
3789            connection_id: u64,
3790            status: &'a StatusResponse,
3791        }
3792
3793        let challenge = C2ServerChallenge {
3794            build_stamp: BUILD_STAMP.to_owned(),
3795            server_nonce: [1; C2_AUTH_NONCE_BYTES],
3796            server_proof: [2; C2_AUTH_PROOF_BYTES],
3797            compatibility: None,
3798        };
3799        let legacy_challenge = LegacyChallenge {
3800            build_stamp: BUILD_STAMP.to_owned(),
3801            server_nonce: [1; C2_AUTH_NONCE_BYTES],
3802            server_proof: [2; C2_AUTH_PROOF_BYTES],
3803        };
3804        assert_eq!(
3805            serde_json::to_vec(&challenge).unwrap(),
3806            serde_json::to_vec(&legacy_challenge).unwrap(),
3807        );
3808
3809        let status = StatusResponse {
3810            api_version: C2_API_VERSION,
3811            ready: true,
3812            observed_at_unix_ms: 7,
3813            nodes: BTreeMap::new(),
3814        };
3815        let hello = C2Hello {
3816            build_stamp: BUILD_STAMP.to_owned(),
3817            connection_id: 11,
3818            status: status.clone(),
3819            compatibility: None,
3820        };
3821        let legacy_hello = LegacyHello {
3822            build_stamp: BUILD_STAMP.to_owned(),
3823            connection_id: 11,
3824            status: &status,
3825        };
3826        assert_eq!(
3827            serde_json::to_vec(&hello).unwrap(),
3828            serde_json::to_vec(&legacy_hello).unwrap(),
3829        );
3830    }
3831
3832    #[test]
3833    fn c2_terminal_frame_event_projection_wire_contract_is_exact() {
3834        let source = NodeEvent::TerminalFrame {
3835            address: SessionAddress {
3836                workspace_id: WorkspaceId::new("primary").unwrap(),
3837                session: gate4agent_node_protocol::SessionKey {
3838                    instance_id: AgentInstanceId(7),
3839                    generation: SessionGeneration(3),
3840                },
3841            },
3842            frame: TerminalFrame {
3843                sequence: 11,
3844                size: TerminalSize { rows: 24, columns: 80 },
3845                cursor_row: 2,
3846                cursor_column: 4,
3847                contents: "ready".to_owned(),
3848                formatted: b"ready".to_vec(),
3849                scrollback_formatted: vec![b"previous".to_vec()],
3850                alternate_screen: false,
3851                mouse_protocol_enabled: false,
3852                mouse_protocol_encoding:
3853                    gate4agent_types::TerminalMouseProtocolEncoding::Default,
3854                produced_at_unix_ms: 0,
3855                screen_state: gate4agent_types::PtyScreenState::default(),
3856                bracketed_paste: None,
3857            },
3858        };
3859        let event = C2NodeEvent::from(&source);
3860        let json = serde_json::to_string(&event).unwrap();
3861        assert_eq!(
3862            json,
3863            r#"{"kind":"terminal-frame","address":{"workspace_id":"primary","session":{"instance_id":7,"generation":3}},"frame":{"sequence":11,"size":{"rows":24,"columns":80},"cursor_row":2,"cursor_column":4,"contents":"ready","formatted":[114,101,97,100,121],"scrollback_formatted":[[112,114,101,118,105,111,117,115]],"alternate_screen":false,"mouse_protocol_enabled":false,"mouse_protocol_encoding":"default","produced_at_unix_ms":0,"screen_state":{"kind":"unknown"}}}"#,
3864        );
3865        assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), event);
3866    }
3867
3868    /// `C2NodeEvent::AgentStream` passes `gate4agent_node_protocol::
3869    /// AgentStreamChunkV1` straight through -- no re-typed mirror of
3870    /// `AgentStreamChunkKindV1` in this crate -- so the new `Blocked`
3871    /// variant round-trips across the c2 wire unchanged, the same way
3872    /// `Text`/`InteractionPrompt`/every other chunk kind already does.
3873    #[test]
3874    fn c2_agent_stream_projection_passes_the_blocked_chunk_through_unchanged() {
3875        let address = SessionAddress {
3876            workspace_id: WorkspaceId::new("primary").unwrap(),
3877            session: gate4agent_node_protocol::SessionKey {
3878                instance_id: AgentInstanceId(7),
3879                generation: SessionGeneration(3),
3880            },
3881        };
3882        let chunk = AgentStreamChunkV1 {
3883            source_sequence: 11,
3884            kind: gate4agent_node_protocol::AgentStreamChunkKindV1::Blocked {
3885                correlation_id: None,
3886                tool_class: "Write".to_owned(),
3887                authority: gate4agent_node_protocol::BlockAuthorityV1::HostGate,
3888                reason_kind: None,
3889                reason: "blocked by dangerous-command gate: rule=filesystem-wipe".to_owned(),
3890                help: None,
3891            },
3892        };
3893        let source = NodeEvent::AgentStream { address: address.clone(), chunk: chunk.clone() };
3894        let projected = C2NodeEvent::from(&source);
3895        assert_eq!(projected, C2NodeEvent::AgentStream { address, chunk: chunk.clone() });
3896        let json = serde_json::to_string(&projected).unwrap();
3897        assert_eq!(
3898            json,
3899            r#"{"kind":"agent-stream","address":{"workspace_id":"primary","session":{"instance_id":7,"generation":3}},"chunk":{"source_sequence":11,"kind":{"kind":"blocked","correlation_id":null,"tool_class":"Write","authority":"host-gate","reason_kind":null,"reason":"blocked by dangerous-command gate: rule=filesystem-wipe","help":null}}}"#,
3900        );
3901        assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3902    }
3903
3904    #[test]
3905    fn c2_terminal_frame_events_capability_is_optional_and_auth_bound_exactly() {
3906        assert_eq!(
3907            C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
3908            "terminal-frame-events-v1",
3909        );
3910        assert_eq!(
3911            C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
3912            NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
3913        );
3914        let capability = CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).unwrap();
3915        let support = c2_compatibility_support(vec![capability.clone()],
3916        );
3917        assert!(support
3918            .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
3919            .unwrap()
3920            .capabilities
3921            .is_empty());
3922
3923        let offer = ClientCompatibilityOffer {
3924            build_stamp: BUILD_STAMP.to_owned(),
3925            capabilities: vec![capability.clone()],
3926            state_schema: None,
3927        };
3928        let selected = support
3929            .negotiate(&C2ClientHello::negotiating(
3930                [0; C2_AUTH_NONCE_BYTES],
3931                offer.clone(),
3932            ))
3933            .unwrap();
3934        assert_eq!(selected.capabilities, vec![capability]);
3935        let transcript = c2_bound_auth_transcript(
3936            C2AuthDirection::Server,
3937            &[0x11; C2_AUTH_NONCE_BYTES],
3938            &[0x22; C2_AUTH_NONCE_BYTES],
3939            &offer,
3940            &selected,
3941        )
3942        .unwrap();
3943        let hex = transcript
3944            .iter()
3945            .map(|byte| format!("{byte:02x}"))
3946            .collect::<String>();
3947        let stamp = build_stamp_hex();
3948        assert_eq!(
3949            hex,
3950            format!(
3951                concat!(
3952                    "67617465346167656e742d63322d636f6e74726f6c2d617574682d76322d636f6d7061746962696c69747900",
3953                    "{stamp}",
3954                    "01",
3955                    "1111111111111111111111111111111111111111111111111111111111111111",
3956                    "2222222222222222222222222222222222222222222222222222222222222222",
3957                    "6f6666657200",
3958                    "{stamp}",
3959                    "0100",
3960                    "18007465726d696e616c2d6672616d652d6576656e74732d763100",
3961                    "73656c656374656400",
3962                    "{stamp}",
3963                    "0100",
3964                    "18007465726d696e616c2d6672616d652d6576656e74732d7631",
3965                    "060064617277696e0700616172636836340201",
3966                ),
3967                stamp = stamp,
3968            ),
3969        );
3970    }
3971
3972    #[test]
3973    fn c2_acp_control_capability_is_optional_and_negotiable() {
3974        assert_eq!(C2_ACP_CONTROL_CAPABILITY, "acp-control-v1");
3975        assert_eq!(C2_ACP_CONTROL_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY);
3976        let capability = CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).unwrap();
3977        let support = c2_compatibility_support(vec![capability.clone()],
3978        );
3979        assert!(support
3980            .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
3981            .unwrap()
3982            .capabilities
3983            .is_empty());
3984
3985        let offer = ClientCompatibilityOffer {
3986            build_stamp: BUILD_STAMP.to_owned(),
3987            capabilities: vec![capability.clone()],
3988            state_schema: None,
3989        };
3990        let selected = support
3991            .negotiate(&C2ClientHello::negotiating(
3992                [0; C2_AUTH_NONCE_BYTES],
3993                offer,
3994            ))
3995            .unwrap();
3996        assert_eq!(selected.capabilities, vec![capability]);
3997    }
3998
3999    #[test]
4000    fn c2_spawn_spec_defaults_overrides_capability_is_optional_and_auth_bound_exactly() {
4001        assert_eq!(
4002            C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4003            "spawn-spec.defaults-overrides-v1",
4004        );
4005        assert_eq!(
4006            C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4007            NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4008        );
4009        let capability =
4010            CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
4011        let support = c2_compatibility_support(vec![capability.clone()],
4012        );
4013        assert!(support
4014            .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4015            .unwrap()
4016            .capabilities
4017            .is_empty());
4018
4019        let offer = ClientCompatibilityOffer {
4020            build_stamp: BUILD_STAMP.to_owned(),
4021            capabilities: vec![capability.clone()],
4022            state_schema: None,
4023        };
4024        let selected = support
4025            .negotiate(&C2ClientHello::negotiating(
4026                [0; C2_AUTH_NONCE_BYTES],
4027                offer.clone(),
4028            ))
4029            .unwrap();
4030        assert_eq!(selected.capabilities, vec![capability]);
4031        let bound = c2_bound_auth_transcript(
4032            C2AuthDirection::Server,
4033            &[0x11; C2_AUTH_NONCE_BYTES],
4034            &[0x22; C2_AUTH_NONCE_BYTES],
4035            &offer,
4036            &selected,
4037        )
4038        .unwrap();
4039        let without_spawn_spec = c2_bound_auth_transcript(
4040            C2AuthDirection::Server,
4041            &[0x11; C2_AUTH_NONCE_BYTES],
4042            &[0x22; C2_AUTH_NONCE_BYTES],
4043            &ClientCompatibilityOffer {
4044                build_stamp: offer.build_stamp.clone(),
4045                capabilities: Vec::new(),
4046                state_schema: None,
4047            },
4048            &NegotiatedC2ControlCompatibility {
4049                build_stamp: selected.build_stamp.clone(),
4050                capabilities: Vec::new(),
4051                host: selected.host.clone(),
4052                path_semantics: selected.path_semantics.clone(),
4053            },
4054        )
4055        .unwrap();
4056        assert_ne!(bound, without_spawn_spec);
4057        assert!(bound.windows(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY.len()).any(
4058            |window| window == C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY.as_bytes()
4059        ));
4060    }
4061
4062    #[test]
4063    fn c2_spawn_profile_revision_capability_is_optional_and_auth_bound_exactly() {
4064        assert_eq!(
4065            C2_SPAWN_PROFILE_REVISION_CAPABILITY,
4066            "spawn-spec.profile-revision-v1",
4067        );
4068        assert_eq!(
4069            C2_SPAWN_PROFILE_REVISION_CAPABILITY,
4070            NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
4071        );
4072        let capability =
4073            CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
4074        let support = c2_compatibility_support(vec![capability.clone()],
4075        );
4076        assert!(support
4077            .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4078            .unwrap()
4079            .capabilities
4080            .is_empty());
4081
4082        let offer = ClientCompatibilityOffer {
4083            build_stamp: BUILD_STAMP.to_owned(),
4084            capabilities: vec![capability.clone()],
4085            state_schema: None,
4086        };
4087        let selected = support
4088            .negotiate(&C2ClientHello::negotiating(
4089                [0; C2_AUTH_NONCE_BYTES],
4090                offer.clone(),
4091            ))
4092            .unwrap();
4093        assert_eq!(selected.capabilities, vec![capability]);
4094        let bound = c2_bound_auth_transcript(
4095            C2AuthDirection::Server,
4096            &[0x11; C2_AUTH_NONCE_BYTES],
4097            &[0x22; C2_AUTH_NONCE_BYTES],
4098            &offer,
4099            &selected,
4100        )
4101        .unwrap();
4102        assert!(bound.windows(C2_SPAWN_PROFILE_REVISION_CAPABILITY.len()).any(
4103            |window| window == C2_SPAWN_PROFILE_REVISION_CAPABILITY.as_bytes()
4104        ));
4105    }
4106
4107    #[test]
4108    fn c2_worktree_selection_capability_is_optional_and_auth_bound_exactly() {
4109        assert_eq!(C2_WORKTREE_SELECTION_CAPABILITY, "worktree-selection-v1");
4110        assert_eq!(
4111            C2_WORKTREE_SELECTION_CAPABILITY,
4112            NODE_WORKTREE_SELECTION_CAPABILITY,
4113        );
4114        let capability = CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).unwrap();
4115        let support = c2_compatibility_support(vec![capability.clone()],
4116        );
4117        assert!(support
4118            .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4119            .unwrap()
4120            .capabilities
4121            .is_empty());
4122
4123        let offer = ClientCompatibilityOffer {
4124            build_stamp: BUILD_STAMP.to_owned(),
4125            capabilities: vec![capability.clone()],
4126            state_schema: None,
4127        };
4128        let selected = support
4129            .negotiate(&C2ClientHello::negotiating(
4130                [0; C2_AUTH_NONCE_BYTES],
4131                offer.clone(),
4132            ))
4133            .unwrap();
4134        assert_eq!(selected.capabilities, vec![capability]);
4135        let bound = c2_bound_auth_transcript(
4136            C2AuthDirection::Server,
4137            &[0x11; C2_AUTH_NONCE_BYTES],
4138            &[0x22; C2_AUTH_NONCE_BYTES],
4139            &offer,
4140            &selected,
4141        )
4142        .unwrap();
4143        let without_worktree_selection = c2_bound_auth_transcript(
4144            C2AuthDirection::Server,
4145            &[0x11; C2_AUTH_NONCE_BYTES],
4146            &[0x22; C2_AUTH_NONCE_BYTES],
4147            &ClientCompatibilityOffer {
4148                build_stamp: offer.build_stamp.clone(),
4149                capabilities: Vec::new(),
4150                state_schema: None,
4151            },
4152            &NegotiatedC2ControlCompatibility {
4153                build_stamp: selected.build_stamp.clone(),
4154                capabilities: Vec::new(),
4155                host: selected.host.clone(),
4156                path_semantics: selected.path_semantics.clone(),
4157            },
4158        )
4159        .unwrap();
4160        assert_ne!(bound, without_worktree_selection);
4161        assert!(bound.windows(C2_WORKTREE_SELECTION_CAPABILITY.len()).any(
4162            |window| window == C2_WORKTREE_SELECTION_CAPABILITY.as_bytes()
4163        ));
4164    }
4165
4166    #[test]
4167    fn c2_managed_worktree_capability_is_optional_and_auth_bound_exactly() {
4168        assert_eq!(
4169            C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
4170            "managed-worktree-lifecycle-v1",
4171        );
4172        assert_eq!(
4173            C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
4174            "managed-worktree-spawn-v2",
4175        );
4176        assert_eq!(
4177            C2NodeFailure::from(&NodeFailure {
4178                code: NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
4179                message: "private node detail".to_owned(),
4180            })
4181            .message,
4182            "managed worktree profile revision mismatch",
4183        );
4184        let capability =
4185            CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).unwrap();
4186        let support = c2_compatibility_support(vec![capability.clone()],
4187        );
4188        let offer = ClientCompatibilityOffer {
4189            build_stamp: BUILD_STAMP.to_owned(),
4190            capabilities: vec![capability],
4191            state_schema: None,
4192        };
4193        let selected = support
4194            .negotiate(&C2ClientHello::negotiating(
4195                [0; C2_AUTH_NONCE_BYTES],
4196                offer.clone(),
4197            ))
4198            .unwrap();
4199        let bound = c2_bound_auth_transcript(
4200            C2AuthDirection::Server,
4201            &[0x11; C2_AUTH_NONCE_BYTES],
4202            &[0x22; C2_AUTH_NONCE_BYTES],
4203            &offer,
4204            &selected,
4205        )
4206        .unwrap();
4207        assert!(bound
4208            .windows(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY.len())
4209            .any(|window| {
4210                window == C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY.as_bytes()
4211            }));
4212        assert!(support
4213            .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4214            .unwrap()
4215            .capabilities
4216            .is_empty());
4217    }
4218
4219    #[test]
4220    fn c2_child_environment_profile_capability_is_optional_and_auth_bound_exactly() {
4221        assert_eq!(
4222            C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
4223            "child-environment-profile-v1",
4224        );
4225        let capability =
4226            CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).unwrap();
4227        let support = c2_compatibility_support(vec![capability.clone()],
4228        );
4229        let offer = ClientCompatibilityOffer {
4230            build_stamp: BUILD_STAMP.to_owned(),
4231            capabilities: vec![capability],
4232            state_schema: None,
4233        };
4234        let selected = support
4235            .negotiate(&C2ClientHello::negotiating(
4236                [0; C2_AUTH_NONCE_BYTES],
4237                offer.clone(),
4238            ))
4239            .unwrap();
4240        let bound = c2_bound_auth_transcript(
4241            C2AuthDirection::Server,
4242            &[0x11; C2_AUTH_NONCE_BYTES],
4243            &[0x22; C2_AUTH_NONCE_BYTES],
4244            &offer,
4245            &selected,
4246        )
4247        .unwrap();
4248        assert!(bound
4249            .windows(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.len())
4250            .any(|window| {
4251                window == C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.as_bytes()
4252            }));
4253        assert!(support
4254            .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4255            .unwrap()
4256            .capabilities
4257            .is_empty());
4258    }
4259
4260    #[test]
4261    fn c2_session_bundle_materialization_is_auth_bound_and_projected_as_opaque_metadata() {
4262        assert_eq!(
4263            C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4264            "session-bundle-materialization-v1",
4265        );
4266        assert_eq!(
4267            C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4268            NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4269        );
4270        let capability =
4271            CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
4272        let support = c2_compatibility_support(vec![capability.clone()],
4273        );
4274        let offer = ClientCompatibilityOffer {
4275            build_stamp: BUILD_STAMP.to_owned(),
4276            capabilities: vec![capability.clone()],
4277            state_schema: None,
4278        };
4279        let selected = support
4280            .negotiate(&C2ClientHello::negotiating(
4281                [0; C2_AUTH_NONCE_BYTES],
4282                offer.clone(),
4283            ))
4284            .unwrap();
4285        assert_eq!(selected.capabilities, vec![capability]);
4286        let bound = c2_bound_auth_transcript(
4287            C2AuthDirection::Server,
4288            &[0x11; C2_AUTH_NONCE_BYTES],
4289            &[0x22; C2_AUTH_NONCE_BYTES],
4290            &offer,
4291            &selected,
4292        )
4293        .unwrap();
4294        assert!(bound
4295            .windows(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.len())
4296            .any(|window| {
4297                window == C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.as_bytes()
4298            }));
4299
4300        let mut record = private_session_record();
4301        record.bundle = Some(ResolvedBundleReceipt {
4302            id: SpawnBundleId::new("review-bundle").unwrap(),
4303            revision: SpawnBundleRevision::new("review-bundle.r1").unwrap(),
4304            digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
4305                .unwrap(),
4306        });
4307        let projected = C2ManagedSessionRecord::from(&record);
4308        let json = serde_json::to_string(&projected).unwrap();
4309        assert!(json.contains(
4310            r#""bundle":{"id":"review-bundle","revision":"review-bundle.r1","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#,
4311        ));
4312        assert_private_record_fields_absent(&json);
4313        assert!(C2NodeEvent::SessionRecordUpserted { record: projected }
4314            .requires_session_bundle_materialization_capability());
4315    }
4316
4317    #[test]
4318    fn c2_history_context_pack_capability_is_optional_and_auth_bound_exactly() {
4319        assert_eq!(C2_HISTORY_CONTEXT_PACK_CAPABILITY, "history-context-pack-v1");
4320        assert_eq!(
4321            C2_HISTORY_CONTEXT_PACK_CAPABILITY,
4322            NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
4323        );
4324        let capability = CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
4325        let support = c2_compatibility_support(vec![capability.clone()],
4326        );
4327        assert!(support
4328            .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4329            .unwrap()
4330            .capabilities
4331            .is_empty());
4332
4333        let offer = ClientCompatibilityOffer {
4334            build_stamp: BUILD_STAMP.to_owned(),
4335            capabilities: vec![capability.clone()],
4336            state_schema: None,
4337        };
4338        let selected = support
4339            .negotiate(&C2ClientHello::negotiating(
4340                [0; C2_AUTH_NONCE_BYTES],
4341                offer.clone(),
4342            ))
4343            .unwrap();
4344        assert_eq!(selected.capabilities, vec![capability]);
4345        let bound = c2_bound_auth_transcript(
4346            C2AuthDirection::Server,
4347            &[0x11; C2_AUTH_NONCE_BYTES],
4348            &[0x22; C2_AUTH_NONCE_BYTES],
4349            &offer,
4350            &selected,
4351        )
4352        .unwrap();
4353        assert!(bound
4354            .windows(C2_HISTORY_CONTEXT_PACK_CAPABILITY.len())
4355            .any(|window| window == C2_HISTORY_CONTEXT_PACK_CAPABILITY.as_bytes()));
4356
4357        for code in [
4358            NodeFailureCode::UnknownContextPack,
4359            NodeFailureCode::ContextPackBusy,
4360            NodeFailureCode::ContextPackMaterializationFailed,
4361        ] {
4362            let failure = C2NodeFailure::from(&NodeFailure {
4363                code,
4364                message: "private context backend detail".to_owned(),
4365            });
4366            assert!(failure.requires_history_context_pack_capability());
4367            assert!(!failure.message.contains("private"));
4368        }
4369        assert!(!C2NodeFailure::from(&NodeFailure {
4370            code: NodeFailureCode::UnknownSession,
4371            message: "private session backend detail".to_owned(),
4372        })
4373        .requires_history_context_pack_capability());
4374    }
4375
4376    #[test]
4377    fn c2_history_context_pack_requests_are_exact_and_capability_gated() {
4378        let session = SessionAddress {
4379            workspace_id: WorkspaceId::new("primary").unwrap(),
4380            session: gate4agent_node_protocol::SessionKey {
4381                instance_id: AgentInstanceId(41),
4382                generation: SessionGeneration(3),
4383            },
4384        };
4385        let requests = [
4386            (
4387                NodeRequest::DiscoverHistory { session: session.clone(), limit: 4 },
4388                r#"{"kind":"discover-history","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"limit":4}"#,
4389            ),
4390            (
4391                NodeRequest::LoadHistory {
4392                    session: session.clone(),
4393                    candidate_id: "candidate-7".to_owned(),
4394                },
4395                r#"{"kind":"load-history","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"candidate_id":"candidate-7"}"#,
4396            ),
4397            (
4398                NodeRequest::ExportContextPack { session },
4399                r#"{"kind":"export-context-pack","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}}}"#,
4400            ),
4401            (
4402                NodeRequest::ForgetContextPack {
4403                    context_id: SpawnContextId::new("context-review-7").unwrap(),
4404                },
4405                r#"{"kind":"forget-context-pack","context_id":"context-review-7"}"#,
4406            ),
4407        ];
4408
4409        for (request, expected) in requests {
4410            assert_eq!(request.required_capability(), Some(C2_HISTORY_CONTEXT_PACK_CAPABILITY));
4411            assert!(request.requires_history_context_pack_capability());
4412            assert_eq!(serde_json::to_string(&request).unwrap(), expected);
4413            assert_eq!(serde_json::from_str::<NodeRequest>(expected).unwrap(), request);
4414        }
4415        assert!(serde_json::from_str::<NodeRequest>(
4416            r#"{"kind":"export-context-pack","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"path":"private.jsonl"}"#,
4417        )
4418        .is_err());
4419    }
4420
4421    #[test]
4422    fn c2_session_record_context_export_is_exact_correlated_and_private() {
4423        assert_eq!(
4424            C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
4425            "session-record-context-export-v1",
4426        );
4427        let session = SessionAddress {
4428            workspace_id: WorkspaceId::new("primary").unwrap(),
4429            session: gate4agent_node_protocol::SessionKey {
4430                instance_id: AgentInstanceId(41),
4431                generation: SessionGeneration(3),
4432            },
4433        };
4434        let record_id = SessionRecordId::new("record-context-41").unwrap();
4435        let request = NodeRequest::ExportContextPackForSessionRecord {
4436            record_id: record_id.clone(),
4437            session: session.clone(),
4438        };
4439        assert_eq!(
4440            request.required_capability(),
4441            Some(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY),
4442        );
4443        assert!(request.requires_history_context_pack_capability());
4444        let request_json = serde_json::to_string(&request).unwrap();
4445        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
4446
4447        let response = C2NodeResponse::from(
4448            &NodeResponse::ContextPackForSessionRecordExported {
4449                record_id,
4450                session,
4451                context: context_pack_receipt(),
4452            },
4453        );
4454        assert!(response.requires_history_context_pack_capability());
4455        assert!(response.requires_session_record_context_export_capability());
4456        let response_json = serde_json::to_string(&response).unwrap();
4457        assert_eq!(
4458            serde_json::from_str::<C2NodeResponse>(&response_json).unwrap(),
4459            response,
4460        );
4461        for private in [
4462            "candidate_id",
4463            "session_id_hint",
4464            "provider_session",
4465            "messages",
4466            "model",
4467            "path",
4468        ] {
4469            assert!(!request_json.contains(private), "request leaked {private}");
4470            assert!(!response_json.contains(private), "response leaked {private}");
4471        }
4472    }
4473
4474    #[test]
4475    fn c2_context_metadata_projects_through_records_snapshots_events_and_responses() {
4476        let context = context_pack_receipt();
4477        let mut source = private_session_record();
4478        source.context_id = Some(context.id.clone());
4479        source.context = Some(context.clone());
4480        let projected = C2ManagedSessionRecord::from(&source);
4481        assert!(projected.context_binding_is_valid());
4482        assert!(projected.requires_history_context_pack_capability());
4483        assert_eq!(projected.context_id.as_ref(), Some(&context.id));
4484        assert_eq!(projected.context.as_ref(), Some(&context));
4485
4486        let record_json = serde_json::to_string(&projected).unwrap();
4487        assert_private_record_fields_absent(&record_json);
4488        assert_eq!(
4489            serde_json::from_str::<C2ManagedSessionRecord>(&record_json).unwrap(),
4490            projected,
4491        );
4492        let mut mismatched = serde_json::from_str::<serde_json::Value>(&record_json).unwrap();
4493        mismatched["context_id"] = serde_json::json!("context-other");
4494        assert!(serde_json::from_value::<C2ManagedSessionRecord>(mismatched).is_err());
4495
4496        let snapshot = C2NodeSnapshot::from(&NodeSnapshot {
4497            node_id: NodeId::new("node-a").unwrap(),
4498            enabled_providers: vec![provider("codex")],
4499            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
4500            workspaces: Vec::new(),
4501            session_records: vec![source.clone()],
4502            managed_worktrees: Vec::new(),
4503            launch_inventory: None,
4504            agent_progress: Vec::new(),
4505        });
4506        assert!(snapshot.requires_history_context_pack_capability());
4507        let event = C2NodeEvent::from(&NodeEvent::SessionRecordUpserted {
4508            record: source.clone(),
4509        });
4510        assert!(event.requires_history_context_pack_capability());
4511
4512        let updated = C2NodeResponse::from(&NodeResponse::SessionRecordUpdated {
4513            record: source,
4514        });
4515        assert!(updated.requires_history_context_pack_capability());
4516        assert!(serde_json::to_string(&updated).unwrap().contains("context-review-7"));
4517
4518        let exported = C2NodeResponse::from(&NodeResponse::ContextPackExported {
4519            context: context.clone(),
4520        });
4521        assert!(exported.requires_history_context_pack_capability());
4522        let json = serde_json::to_string(&exported).unwrap();
4523        assert!(json.contains(r#""kind":"context-pack-exported""#));
4524        assert!(json.contains(r#""source_message_count":9"#));
4525        assert!(!json.contains("messages"));
4526        assert!(!json.contains("path"));
4527        assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), exported);
4528    }
4529
4530    #[test]
4531    fn c2_exported_context_projects_through_records_and_is_provider_correlated() {
4532        let exported = context_pack_receipt();
4533        let mut source = private_session_record();
4534        source.exported_context = Some(exported.clone());
4535        let projected = C2ManagedSessionRecord::from(&source);
4536        assert!(projected.exported_context_is_valid());
4537        assert_eq!(projected.exported_context.as_ref(), Some(&exported));
4538
4539        let record_json = serde_json::to_string(&projected).unwrap();
4540        assert_private_record_fields_absent(&record_json);
4541        assert_eq!(
4542            serde_json::from_str::<C2ManagedSessionRecord>(&record_json).unwrap(),
4543            projected,
4544        );
4545
4546        let mut mismatched_provider =
4547            serde_json::from_str::<serde_json::Value>(&record_json).unwrap();
4548        mismatched_provider["exported_context"]["lineage"]["source_provider"] =
4549            serde_json::json!("claude");
4550        assert!(serde_json::from_value::<C2ManagedSessionRecord>(mismatched_provider).is_err());
4551    }
4552
4553    #[test]
4554    fn c2_history_responses_are_exact_bounded_metadata_without_messages_or_paths() {
4555        let session = SessionAddress {
4556            workspace_id: WorkspaceId::new("primary").unwrap(),
4557            session: gate4agent_node_protocol::SessionKey {
4558                instance_id: AgentInstanceId(41),
4559                generation: SessionGeneration(3),
4560            },
4561        };
4562        let discovered = C2NodeResponse::from(&NodeResponse::HistoryDiscovered {
4563            session: session.clone(),
4564            candidates: vec![HistoryCandidateSummary {
4565                id: "candidate-7".to_owned(),
4566                session_id_hint: "session-hint-7".to_owned(),
4567                modified_at_unix_ms: Some(77),
4568            }],
4569        });
4570        let loaded = C2NodeResponse::from(&NodeResponse::HistoryLoaded {
4571            session,
4572            session_id: "session-loaded-7".to_owned(),
4573            message_count: 12,
4574            completed_turn_count: Some(5),
4575        });
4576        let forgotten = C2NodeResponse::from(&NodeResponse::ContextPackForgotten {
4577            context_id: SpawnContextId::new("context-review-7").unwrap(),
4578        });
4579        assert_eq!(
4580            serde_json::to_string(&discovered).unwrap(),
4581            r#"{"kind":"history-discovered","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"candidates":[{"id":"candidate-7","session_id_hint":"session-hint-7","modified_at_unix_ms":77}]}"#,
4582        );
4583        assert_eq!(
4584            serde_json::to_string(&loaded).unwrap(),
4585            r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"session_id":"session-loaded-7","message_count":12,"completed_turn_count":5}"#,
4586        );
4587        let legacy_loaded_json = r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"session_id":"session-loaded-7","message_count":12}"#;
4588        let legacy_loaded = serde_json::from_str::<C2NodeResponse>(legacy_loaded_json).unwrap();
4589        assert_eq!(serde_json::to_string(&legacy_loaded).unwrap(), legacy_loaded_json);
4590        assert!(matches!(
4591            legacy_loaded,
4592            C2NodeResponse::HistoryLoaded {
4593                completed_turn_count: None,
4594                ..
4595            }
4596        ));
4597        assert_eq!(
4598            serde_json::to_string(&forgotten).unwrap(),
4599            r#"{"kind":"context-pack-forgotten","context_id":"context-review-7"}"#,
4600        );
4601        for response in [discovered, loaded, forgotten] {
4602            assert!(response.requires_history_context_pack_capability());
4603            let json = serde_json::to_string(&response).unwrap();
4604            assert!(!json.contains("messages"));
4605            assert!(!json.contains("path"));
4606            assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), response);
4607        }
4608        assert!(serde_json::from_str::<C2NodeResponse>(
4609            r#"{"kind":"history-discovered","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"candidates":[{"id":"duplicate","session_id_hint":"one","modified_at_unix_ms":null},{"id":"duplicate","session_id_hint":"two","modified_at_unix_ms":null}]}"#,
4610        )
4611        .is_err());
4612    }
4613
4614    #[test]
4615    fn native_session_catalog_projection_is_exact_and_capability_bound() {
4616        assert_eq!(
4617            C2_NATIVE_SESSION_CATALOG_CAPABILITY,
4618            "native-session-catalog-v2"
4619        );
4620        let route = NativeSessionCatalogRoute::workspace(
4621            WorkspaceId::new("primary").unwrap(),
4622            provider("codex"),
4623        );
4624        let projected = C2NodeResponse::from(&NodeResponse::NativeSessionsCataloged {
4625            route: route.clone(),
4626            entries: vec![NativeSessionCatalogEntry {
4627                selection_id: "hist_selection_7".to_owned(),
4628                title: Some("Review".to_owned()),
4629                modified_at_unix_ms: Some(77),
4630                model: Some("model-7".to_owned()),
4631                message_count: 8,
4632                completed_turn_count: Some(4),
4633                external_group: None,
4634                record_id: Some(SessionRecordId::new("record-7").unwrap()),
4635            }],
4636            summary: Some(NativeSessionCatalogSummary {
4637                catalog_revision: 7,
4638                recent_cutoff_unix_ms: 70,
4639                recent_total_count: 1,
4640                older_total_count: 3,
4641                recent_next_after_selection_id: None,
4642                recent_has_more: false,
4643            }),
4644        });
4645        assert!(projected.requires_native_session_catalog_capability());
4646        let json = serde_json::to_string(&projected).unwrap();
4647        for forbidden in [
4648            "session_id",
4649            "cwd",
4650            "candidate",
4651            "path",
4652            "messages",
4653            "tokens",
4654            "documents",
4655            "raw",
4656        ] {
4657            assert!(!json.contains(forbidden));
4658        }
4659        assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), projected);
4660        assert!(serde_json::from_str::<C2NodeResponse>(
4661            r#"{"kind":"native-sessions-cataloged","workspace_id":"primary","provider":"codex","entries":[]}"#,
4662        )
4663        .is_err());
4664
4665        let paged = C2NodeResponse::from(&NodeResponse::NativeSessionsPaged {
4666            route,
4667            page: NativeSessionCatalogPage {
4668                window: NativeSessionCatalogWindow::Older,
4669                revision: 7,
4670                entries: Vec::new(),
4671                next_after_selection_id: None,
4672                remaining_count: 0,
4673                has_more: false,
4674            },
4675        });
4676        assert!(paged.requires_native_session_catalog_paging_capability());
4677    }
4678
4679    #[test]
4680    fn session_record_preview_projection_does_not_expose_native_identity() {
4681        let response = NodeResponse::SessionRecordPreviewed {
4682            record_id: SessionRecordId::new("record-7").unwrap(),
4683            preview: gate4agent_types::SessionRecordPreview {
4684                title: Some("Review".to_owned()),
4685                modified_at_unix_ms: Some(77),
4686                model: Some("model-7".to_owned()),
4687                message_count: 3,
4688                message_count_exact: true,
4689                completed_turn_count: Some(1),
4690                total_tokens: None,
4691                truncated: true,
4692                messages: vec![gate4agent_types::NativeSessionPreviewMessage {
4693                    role: gate4agent_types::HistoryMessageRole::User,
4694                    text: "visible dialogue".to_owned(),
4695                }],
4696            },
4697        };
4698        let projected = C2NodeResponse::from(&response);
4699        assert!(projected.requires_native_session_preview_capability());
4700        let json = serde_json::to_string(&projected).unwrap();
4701        for forbidden in ["session_id", "provider", "workspace_id", "cwd", "path", "tokens"] {
4702            assert!(!json.contains(forbidden));
4703        }
4704        assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), projected);
4705    }
4706
4707    #[test]
4708    fn legacy_slim_projection_deliberately_strips_context_metadata() {
4709        let context = context_pack_receipt();
4710        let mut source = private_session_record();
4711        source.context_id = Some(context.id.clone());
4712        source.context = Some(context);
4713        let c2 = C2ManagedSessionRecord::from(&source);
4714
4715        for json in [
4716            serde_json::to_value(SlimManagedSessionRecord::from(&source)).unwrap(),
4717            serde_json::to_value(SlimManagedSessionRecord::from(&c2)).unwrap(),
4718        ] {
4719            assert!(json.get("context_id").is_none());
4720            assert!(json.get("context").is_none());
4721        }
4722    }
4723
4724    #[test]
4725    fn c2_legacy_node_event_bytes_remain_exact_after_terminal_frame_addition() {
4726        assert_eq!(
4727            serde_json::to_vec(&C2NodeEvent::ResyncRequired {
4728                oldest_available_sequence: 7,
4729            })
4730            .unwrap(),
4731            br#"{"kind":"resync-required","oldest_available_sequence":7}"#,
4732        );
4733    }
4734
4735    #[test]
4736    fn c2_compatibility_missing_offer_negotiates_local_build_stamp() {
4737        let support = c2_compatibility_support(Vec::new());
4738
4739        let negotiated = support
4740            .negotiate(&C2ClientHello::new([1; C2_AUTH_NONCE_BYTES]))
4741            .unwrap();
4742
4743        assert_eq!(negotiated.build_stamp, BUILD_STAMP);
4744        assert!(negotiated.capabilities.is_empty());
4745    }
4746
4747    #[test]
4748    fn c2_compatibility_selects_local_build_stamp_and_capability_intersection() {
4749        let shared = CapabilityId::new("terminal-stream").unwrap();
4750        let server_only = CapabilityId::new("server-only").unwrap();
4751        let client_only = CapabilityId::new("client-only").unwrap();
4752        let support = c2_compatibility_support(vec![shared.clone(), server_only]);
4753        let hello = C2ClientHello::negotiating(
4754            [2; C2_AUTH_NONCE_BYTES],
4755            ClientCompatibilityOffer {
4756                build_stamp: BUILD_STAMP.to_owned(),
4757                capabilities: vec![client_only, shared.clone()],
4758                state_schema: None,
4759            },
4760        );
4761
4762        let negotiated = support.negotiate(&hello).unwrap();
4763
4764        assert_eq!(negotiated.build_stamp, BUILD_STAMP);
4765        assert_eq!(negotiated.capabilities, vec![shared]);
4766    }
4767
4768    #[test]
4769    fn c2_compatibility_negotiate_rejects_a_foreign_build_stamp_naming_both_values() {
4770        let support = c2_compatibility_support(Vec::new());
4771        let foreign_stamp = "f".repeat(40);
4772        let hello = C2ClientHello {
4773            build_stamp: foreign_stamp.clone(),
4774            client_nonce: [3; C2_AUTH_NONCE_BYTES],
4775            compatibility: None,
4776        };
4777
4778        let error = support.negotiate(&hello).unwrap_err();
4779        assert!(matches!(
4780            &error,
4781            ProtocolNegotiationError::BuildStampMismatch { local, remote }
4782                if local == BUILD_STAMP && remote == &foreign_stamp,
4783        ));
4784        assert_eq!(
4785            error.to_string(),
4786            format!("build stamp mismatch: local={BUILD_STAMP} remote={foreign_stamp}"),
4787        );
4788    }
4789
4790    #[test]
4791    fn c2_compatibility_bound_auth_transcript_is_exact_and_selection_sensitive() {
4792        let capability = CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).unwrap();
4793        let offer = ClientCompatibilityOffer {
4794            build_stamp: BUILD_STAMP.to_owned(),
4795            capabilities: vec![capability.clone()],
4796            state_schema: None,
4797        };
4798        let selected = NegotiatedC2ControlCompatibility {
4799            build_stamp: BUILD_STAMP.to_owned(),
4800            capabilities: vec![capability],
4801            host: HostDescriptor {
4802                operating_system: OperatingSystemId::new("windows").unwrap(),
4803                architecture: ArchitectureId::new("x86_64").unwrap(),
4804            },
4805            path_semantics: PathSemantics {
4806                style: PathStyle::Windows,
4807                encoding: PathEncoding::Utf8,
4808            },
4809        };
4810        let transcript = c2_bound_auth_transcript(
4811            C2AuthDirection::Server,
4812            &[0x11; C2_AUTH_NONCE_BYTES],
4813            &[0x22; C2_AUTH_NONCE_BYTES],
4814            &offer,
4815            &selected,
4816        ).unwrap();
4817        let hex = transcript.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
4818
4819        let stamp = build_stamp_hex();
4820        assert_eq!(
4821            hex,
4822            format!(
4823                concat!(
4824                    "67617465346167656e742d63322d636f6e74726f6c2d617574682d76322d636f6d7061746962696c69747900",
4825                    "{stamp}",
4826                    "01",
4827                    "1111111111111111111111111111111111111111111111111111111111111111",
4828                    "2222222222222222222222222222222222222222222222222222222222222222",
4829                    "6f6666657200",
4830                    "{stamp}",
4831                    "0100",
4832                    "1600636f6d7061746962696c6974792e6d6574616461746100",
4833                    "73656c656374656400",
4834                    "{stamp}",
4835                    "0100",
4836                    "1600636f6d7061746962696c6974792e6d65746164617461",
4837                    "070077696e646f777306007838365f36340101",
4838                ),
4839                stamp = stamp,
4840            ),
4841        );
4842
4843        let mut tampered = selected;
4844        tampered.path_semantics.style = PathStyle::Posix;
4845        assert_ne!(
4846            transcript,
4847            c2_bound_auth_transcript(
4848                C2AuthDirection::Server,
4849                &[0x11; C2_AUTH_NONCE_BYTES],
4850                &[0x22; C2_AUTH_NONCE_BYTES],
4851                &offer,
4852                &tampered,
4853            ).unwrap(),
4854        );
4855    }
4856
4857    #[test]
4858    fn c2_compatibility_preserves_foreign_host_and_opaque_path() {
4859        let support = c2_compatibility_support(Vec::new(),
4860        );
4861        let negotiated = support
4862            .negotiate(&C2ClientHello::new([4; C2_AUTH_NONCE_BYTES]))
4863            .unwrap();
4864        let projected = C2NodeSnapshot::from(&NodeSnapshot {
4865            node_id: NodeId::new("remote-mac").unwrap(),
4866            enabled_providers: Vec::new(),
4867            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
4868            workspaces: vec![WorkspaceSnapshot {
4869                workspace_id: WorkspaceId::new("repo").unwrap(),
4870                canonical_root: host_path("/srv/CaseSensitive/../literal-root"),
4871                sessions: Vec::new(),
4872                worktree_service_mode: None,
4873                managed_worktree_profiles: None,
4874            }],
4875            session_records: Vec::new(),
4876            managed_worktrees: Vec::new(),
4877            launch_inventory: None,
4878            agent_progress: Vec::new(),
4879        });
4880        let json = serde_json::to_string(&(projected, negotiated)).unwrap();
4881        let (projected, negotiated) = serde_json::from_str::<(
4882            C2NodeSnapshot,
4883            NegotiatedC2ControlCompatibility,
4884        )>(&json).unwrap();
4885
4886        assert_eq!(
4887            projected.workspaces[0].canonical_root.display_text(),
4888            "/srv/CaseSensitive/../literal-root",
4889        );
4890        assert_eq!(negotiated.host.operating_system.as_str(), "darwin");
4891        assert_eq!(negotiated.host.architecture.as_str(), "aarch64");
4892        assert_eq!(negotiated.path_semantics.style, PathStyle::Posix);
4893    }
4894
4895    #[test]
4896    fn c2_workspace_inspection_preserves_legacy_utf8_repository_path_shape() {
4897        let inspection = C2WorkspaceInspection {
4898            workspace_id: WorkspaceId::new("repo").unwrap(),
4899            entries: vec![WorkspaceEntry {
4900                relative_path: repository_path(r"src\literal/main.rs"),
4901                kind: WorkspaceEntryKind::File,
4902            }],
4903            tree_truncated: false,
4904            git: C2GitSnapshot {
4905                is_repository: true,
4906                branch: Some("main".to_owned()),
4907                status: Vec::new(),
4908                recent_commits: Vec::new(),
4909                worktrees: Vec::new(),
4910                managed_worktree: None,
4911                truncated: false,
4912                diagnostic_present: false,
4913            },
4914            truncation: None,
4915        };
4916
4917        let json = serde_json::to_string(&inspection).unwrap();
4918        assert_eq!(
4919            json,
4920            r#"{"workspace_id":"repo","entries":[{"relative_path":"src\\literal/main.rs","kind":"file"}],"tree_truncated":false,"git":{"is_repository":true,"branch":"main","status":[],"recent_commits":[],"worktrees":[],"truncated":false,"diagnostic_present":false}}"#,
4921        );
4922        assert_eq!(serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(), inspection);
4923    }
4924
4925    #[test]
4926    fn c2_workspace_inspection_roundtrips_all_tagged_repository_path_fields() {
4927        let entry_path = RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xfe]).unwrap();
4928        let status_path = RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xff]).unwrap();
4929        let previous_path = RepositoryPath::unix_bytes(vec![b'o', b'l', b'd', b'/', 0xfd]).unwrap();
4930        let inspection = C2WorkspaceInspection {
4931            workspace_id: WorkspaceId::new("repo").unwrap(),
4932            entries: vec![WorkspaceEntry {
4933                relative_path: entry_path,
4934                kind: WorkspaceEntryKind::File,
4935            }],
4936            tree_truncated: false,
4937            git: C2GitSnapshot {
4938                is_repository: true,
4939                branch: None,
4940                status: vec![GitStatusEntry {
4941                    index_status: "R".to_owned(),
4942                    worktree_status: " ".to_owned(),
4943                    path: status_path,
4944                    previous_path: Some(previous_path),
4945                }],
4946                recent_commits: Vec::new(),
4947                worktrees: Vec::new(),
4948                managed_worktree: None,
4949                truncated: false,
4950                diagnostic_present: false,
4951            },
4952            truncation: None,
4953        };
4954
4955        let json = serde_json::to_string(&inspection).unwrap();
4956        assert!(json.contains(r#""relative_path":{"kind":"unix-bytes""#));
4957        assert!(json.contains(r#""path":{"kind":"unix-bytes""#));
4958        assert!(json.contains(r#""previous_path":{"kind":"unix-bytes""#));
4959        assert_eq!(serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(), inspection);
4960    }
4961
4962    #[test]
4963    fn c2_workspace_inspection_projects_and_validates_managed_git_scope_without_paths() {
4964        let workspace_id = WorkspaceId::new("managed-a").unwrap();
4965        let source_workspace_id = WorkspaceId::new("primary").unwrap();
4966        let inspection = WorkspaceInspection {
4967            workspace_id: workspace_id.clone(),
4968            entries: Vec::new(),
4969            tree_truncated: false,
4970            git: GitSnapshot {
4971                is_repository: true,
4972                branch: Some("gate4agent/a".to_owned()),
4973                status: Vec::new(),
4974                recent_commits: Vec::new(),
4975                worktrees: Vec::new(),
4976                managed_worktree: Some(ManagedWorktreeGitScope {
4977                    lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
4978                    source_workspace_id: source_workspace_id.clone(),
4979                    branch: "gate4agent/a".to_owned(),
4980                    base_commit: gate4agent_node_protocol::GitObjectId::new(
4981                        "0123456789abcdef0123456789abcdef01234567".to_owned(),
4982                    )
4983                    .unwrap(),
4984                    active_session_count: 1,
4985                    managed_record_count: 1,
4986                }),
4987                truncated: false,
4988                diagnostic: None,
4989            },
4990            truncation: None,
4991        };
4992        let projected = C2WorkspaceInspection::from(&inspection);
4993        let json = serde_json::to_string(&projected).unwrap();
4994        assert!(json.contains("\"managed_worktree\""));
4995        assert!(json.contains("\"lease_id\":\"mw-a\""));
4996        assert!(!json.contains("target_root"));
4997        assert_eq!(
4998            serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(),
4999            projected,
5000        );
5001
5002        let invalid = json.replace(
5003            "\"source_workspace_id\":\"primary\"",
5004            "\"source_workspace_id\":\"managed-a\"",
5005        );
5006        assert!(serde_json::from_str::<C2WorkspaceInspection>(&invalid).is_err());
5007
5008        let mut invalid_node = inspection;
5009        invalid_node.git.branch = Some("gate4agent/b".to_owned());
5010        assert!(C2WorkspaceInspection::from(&invalid_node)
5011            .git
5012            .managed_worktree
5013            .is_none());
5014    }
5015
5016    #[test]
5017    fn slim_inventory_is_deterministic_and_excludes_terminal_history() {
5018        let snapshot = NodeSnapshot {
5019            node_id: NodeId::new("node-a").unwrap(),
5020            enabled_providers: vec![provider("codex"), provider("claude"), provider("codex")],
5021            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5022            workspaces: vec![
5023                WorkspaceSnapshot { workspace_id: WorkspaceId::new("z-work").unwrap(), canonical_root: host_path("z"), sessions: Vec::new(), worktree_service_mode: None, managed_worktree_profiles: None },
5024                WorkspaceSnapshot {
5025                    workspace_id: WorkspaceId::new("a-work").unwrap(),
5026                    canonical_root: host_path("a"),
5027                    sessions: vec![fixture_session()],
5028                    worktree_service_mode: None,
5029                    managed_worktree_profiles: None,
5030                },
5031            ],
5032            session_records: Vec::new(),
5033            managed_worktrees: Vec::new(),
5034            launch_inventory: None,
5035            agent_progress: Vec::new(),
5036        };
5037        let slim = SlimNodeInventory::from_snapshot(&snapshot);
5038        assert_eq!(slim.enabled_providers, vec![provider("claude"), provider("codex")]);
5039        assert_eq!(slim.workspaces.keys().map(WorkspaceId::as_str).collect::<Vec<_>>(), vec!["a-work", "z-work"]);
5040        let session = &slim.workspaces[&WorkspaceId::new("a-work").unwrap()].sessions[0];
5041        assert_eq!(session.transport, TransportKind::Pty);
5042        assert_eq!(session.process_id, Some(1234));
5043        assert_eq!(session.terminal_size, Some(TerminalSize { rows: 40, columns: 120 }));
5044        assert!(session.operation_pending);
5045        assert!(session.input_pending);
5046        let json = serde_json::to_string(&slim).unwrap();
5047        assert!(!json.contains("terminal_frame"));
5048        assert!(!json.contains("history"));
5049    }
5050
5051    /// A `SlimSession` payload from a node that predates `screen_state`
5052    /// decodes the missing key as `Unknown`, never as the optimistic
5053    /// `Ready` -- the entire point of `#[serde(default)]` on that field.
5054    #[test]
5055    fn slim_session_json_omitting_screen_state_decodes_to_unknown_not_ready() {
5056        let json = r#"{
5057            "instance_id": 7,
5058            "generation": 2,
5059            "agent_id": "codex",
5060            "transport": "pty",
5061            "status": "running",
5062            "process_id": 1234,
5063            "terminal_size": null,
5064            "operation_pending": true,
5065            "input_pending": false
5066        }"#;
5067        let session = serde_json::from_str::<SlimSession>(json).unwrap();
5068        assert_eq!(session.screen_state, PtyScreenState::Unknown);
5069        assert_ne!(session.screen_state, PtyScreenState::Ready);
5070    }
5071
5072    /// The slim projection carries the session's non-default screen
5073    /// classification through unchanged from the `SessionSnapshot` it was
5074    /// built from -- proving `SlimNodeInventory::from_snapshot` does not
5075    /// silently drop it back to the default the way an unset field would.
5076    #[test]
5077    fn slim_inventory_projection_carries_a_non_default_screen_state_from_the_session_snapshot() {
5078        let mut session = fixture_session();
5079        session.screen_state = PtyScreenState::OperatorGate {
5080            gate: gate4agent_types::OperatorGateState::new(gate4agent_types::OperatorGateKind::WorkspaceTrust),
5081        };
5082        let snapshot = NodeSnapshot {
5083            node_id: NodeId::new("node-a").unwrap(),
5084            enabled_providers: vec![provider("codex")],
5085            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5086            workspaces: vec![WorkspaceSnapshot {
5087                workspace_id: WorkspaceId::new("a-work").unwrap(),
5088                canonical_root: host_path("a"),
5089                sessions: vec![session.clone()],
5090                worktree_service_mode: None,
5091                managed_worktree_profiles: None,
5092            }],
5093            session_records: Vec::new(),
5094            managed_worktrees: Vec::new(),
5095            launch_inventory: None,
5096            agent_progress: Vec::new(),
5097        };
5098        let slim = SlimNodeInventory::from_snapshot(&snapshot);
5099        let slim_session = &slim.workspaces[&WorkspaceId::new("a-work").unwrap()].sessions[0];
5100        assert_eq!(slim_session.screen_state, session.screen_state);
5101    }
5102
5103    #[test]
5104    fn c2_full_and_slim_snapshots_preserve_launch_and_worktree_inventory() {
5105        let worktree_profiles = WorktreeProfileInventory {
5106            profiles: vec![ManagedWorktreeProfileSummary {
5107                id: WorktreeProfileId::new("review").unwrap(),
5108                revision: WorktreeProfileRevision::new("v1").unwrap(),
5109                retention: ManagedWorktreeRetention::Retain,
5110            }],
5111        };
5112        let launch_inventory = LaunchInventory {
5113            spawn_profiles: Some(vec![SpawnProfileSummary {
5114                id: SpawnProfileId::new("default").unwrap(),
5115                revision: SpawnProfileRevision::new("v1").unwrap(),
5116                environment_profile: None,
5117            }]),
5118            bundles: Some(vec![ResolvedBundleReceipt {
5119                id: SpawnBundleId::new("review").unwrap(),
5120                revision: SpawnBundleRevision::new("v1").unwrap(),
5121                digest: SpawnBundleDigest::new(format!("sha256:{}", "0".repeat(64))).unwrap(),
5122            }]),
5123            network_allowlists: None,
5124        };
5125        let snapshot = NodeSnapshot {
5126            node_id: NodeId::new("node-a").unwrap(),
5127            enabled_providers: vec![provider("codex")],
5128            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5129            workspaces: vec![WorkspaceSnapshot {
5130                workspace_id: WorkspaceId::new("repo").unwrap(),
5131                canonical_root: host_path("repo"),
5132                sessions: Vec::new(),
5133                worktree_service_mode: Some(WorktreeServiceMode::Managed),
5134                managed_worktree_profiles: Some(worktree_profiles.clone()),
5135            }],
5136            session_records: Vec::new(),
5137            managed_worktrees: Vec::new(),
5138            launch_inventory: Some(launch_inventory.clone()),
5139            agent_progress: Vec::new(),
5140        };
5141
5142        let full = C2NodeSnapshot::from(&snapshot);
5143        let slim_from_node = SlimNodeInventory::from_snapshot(&snapshot);
5144        let slim_from_c2 = SlimNodeInventory::from_c2_snapshot(&full);
5145        assert_eq!(full.launch_inventory.as_ref(), Some(&launch_inventory));
5146        assert_eq!(slim_from_node.launch_inventory.as_ref(), Some(&launch_inventory));
5147        assert_eq!(slim_from_c2.launch_inventory.as_ref(), Some(&launch_inventory));
5148        assert_eq!(
5149            full.workspaces[0].worktree_service_mode,
5150            Some(WorktreeServiceMode::Managed),
5151        );
5152        assert_eq!(
5153            slim_from_node.workspaces[&WorkspaceId::new("repo").unwrap()]
5154                .worktree_service_mode,
5155            Some(WorktreeServiceMode::Managed),
5156        );
5157        assert_eq!(
5158            slim_from_c2.workspaces[&WorkspaceId::new("repo").unwrap()]
5159                .worktree_service_mode,
5160            Some(WorktreeServiceMode::Managed),
5161        );
5162        assert_eq!(
5163            full.workspaces[0].managed_worktree_profiles.as_ref(),
5164            Some(&worktree_profiles),
5165        );
5166        assert_eq!(
5167            slim_from_node.workspaces[&WorkspaceId::new("repo").unwrap()]
5168                .managed_worktree_profiles
5169                .as_ref(),
5170            Some(&worktree_profiles),
5171        );
5172
5173        let legacy = serde_json::from_value::<C2WorkspaceSnapshot>(serde_json::json!({
5174            "workspace_id": "legacy",
5175            "canonical_root": "legacy-root",
5176            "sessions": [],
5177        }))
5178        .unwrap();
5179        assert_eq!(legacy.worktree_service_mode, None);
5180        assert_eq!(legacy.managed_worktree_profiles, None);
5181        let encoded = serde_json::to_value(legacy).unwrap();
5182        assert!(encoded.get("worktree_service_mode").is_none());
5183        assert!(encoded.get("managed_worktree_profiles").is_none());
5184    }
5185
5186    #[test]
5187    fn slim_inventory_provider_contract_projection_is_exact_and_private() {
5188        let mut slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5189            node_id: NodeId::new("node-a").unwrap(),
5190            enabled_providers: vec![provider("codex")],
5191            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5192            workspaces: Vec::new(),
5193            session_records: Vec::new(),
5194            managed_worktrees: Vec::new(),
5195            launch_inventory: None,
5196            agent_progress: Vec::new(),
5197        });
5198        let (provider_contracts, provider_adapter_contracts) =
5199            provider_contract_manifest();
5200        slim.provider_contracts = provider_contracts;
5201        slim.provider_adapter_contracts = provider_adapter_contracts;
5202
5203        let value = serde_json::to_value(&slim).unwrap();
5204        assert_eq!(
5205            value["provider_contracts"],
5206            serde_json::json!([{
5207                "provider": "codex",
5208                "revision": "codex.2026-08",
5209            }]),
5210        );
5211        assert_eq!(
5212            value["provider_adapter_contracts"],
5213            serde_json::json!([{
5214                "provider": "codex",
5215                "family": "pty-semantic",
5216                "adapter_id": "codex",
5217                "revision": "pty-semantic.2026-08",
5218            }]),
5219        );
5220        let json = serde_json::to_string(&value).unwrap();
5221        for forbidden in [
5222            "installed_cli_version",
5223            "executable_path",
5224            "auth_state",
5225            "canary_verdict",
5226            "events",
5227            "routed_response",
5228        ] {
5229            assert!(!json.contains(forbidden), "leaked private field {forbidden}");
5230        }
5231    }
5232
5233    #[test]
5234    fn c2_projection_omits_path_stdout_stderr_env_and_fallback_reason() {
5235        let runtime_statuses = ProviderRuntimeStatuses::new([
5236            ProviderRuntimeStatus::raw_passthrough(
5237                provider("codex"),
5238                Some(ProviderRuntimeVersion::new("0.999.0").unwrap()),
5239            ),
5240        ])
5241        .unwrap();
5242        let snapshot = NodeSnapshot {
5243            node_id: NodeId::new("node-a").unwrap(),
5244            enabled_providers: vec![provider("codex")],
5245            provider_runtime_statuses: runtime_statuses.clone(),
5246            workspaces: Vec::new(),
5247            session_records: Vec::new(),
5248            managed_worktrees: Vec::new(),
5249            launch_inventory: None,
5250            agent_progress: Vec::new(),
5251        };
5252        let projected = C2NodeSnapshot::from(&snapshot);
5253        let inventory = SlimNodeInventory::from_snapshot(&snapshot);
5254        assert_eq!(projected.provider_runtime_statuses, runtime_statuses);
5255        assert_eq!(inventory.provider_runtime_statuses, runtime_statuses);
5256        for encoded in [
5257            serde_json::to_string(&projected).unwrap(),
5258            serde_json::to_string(&inventory).unwrap(),
5259        ] {
5260            assert!(encoded.contains("\"version\":\"0.999.0\""));
5261            for forbidden in [
5262                "launcher", "executable", "stdout", "stderr", "environment", "fallback",
5263                "reason", "arguments",
5264            ] {
5265                assert!(!encoded.contains(forbidden), "leaked field {forbidden}");
5266            }
5267        }
5268    }
5269
5270    #[test]
5271    fn slim_inventory_reports_sessions_hidden_by_workspace_truncation() {
5272        let mut workspaces = (0..MAX_C2_WORKSPACES_PER_NODE)
5273            .map(|index| WorkspaceSnapshot {
5274                workspace_id: WorkspaceId::new(format!("work-{index:02}")).unwrap(),
5275                canonical_root: host_path(format!("root-{index:02}")),
5276                sessions: Vec::new(),
5277                worktree_service_mode: None,
5278                managed_worktree_profiles: None,
5279            })
5280            .collect::<Vec<_>>();
5281        workspaces.push(WorkspaceSnapshot {
5282            workspace_id: WorkspaceId::new("work-zz").unwrap(),
5283            canonical_root: host_path("hidden-root"),
5284            sessions: vec![fixture_session()],
5285            worktree_service_mode: None,
5286            managed_worktree_profiles: None,
5287        });
5288        let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5289            node_id: NodeId::new("node-a").unwrap(),
5290            enabled_providers: Vec::new(),
5291            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5292            workspaces,
5293            session_records: Vec::new(),
5294            managed_worktrees: Vec::new(),
5295            launch_inventory: None,
5296            agent_progress: Vec::new(),
5297        });
5298        assert!(slim.workspaces_truncated);
5299        assert_eq!(slim.session_count, 1);
5300        assert!(slim.sessions_truncated);
5301    }
5302
5303    #[test]
5304    fn control_auth_transcript_is_direction_and_protocol_domain_separated() {
5305        assert_eq!(C2_API_VERSION, 2);
5306        let client_nonce = [3; C2_AUTH_NONCE_BYTES];
5307        let server_nonce = [7; C2_AUTH_NONCE_BYTES];
5308        let server = c2_auth_transcript(C2AuthDirection::Server, &client_nonce, &server_nonce);
5309        let client = c2_auth_transcript(C2AuthDirection::Client, &client_nonce, &server_nonce);
5310        assert_ne!(server, client);
5311        assert!(server.starts_with(b"gate4agent-c2-control-auth-v2\0"));
5312        assert!(!server.windows(b"gate4agent-node-auth-v3".len()).any(|window| window == b"gate4agent-node-auth-v3"));
5313        assert_eq!(&server[server.len() - (C2_AUTH_NONCE_BYTES * 2)..server.len() - C2_AUTH_NONCE_BYTES], &client_nonce);
5314        assert_eq!(&server[server.len() - C2_AUTH_NONCE_BYTES..], &server_nonce);
5315    }
5316
5317    #[test]
5318    fn topology_projection_is_sorted_bounded_and_minimal() {
5319        let nodes = (0..=MAX_C2_NODES).rev().map(|index| {
5320            let node_id = NodeId::new(format!("node-{index:03}")).unwrap();
5321            let incarnation_id = NodeIncarnationId::from_bytes([index as u8; 16]);
5322            let observed = ObservedNode {
5323                endpoint: format!(r"\\.\pipe\node-{index:03}"),
5324                transport_label: "windows-named-pipe".to_owned(),
5325                transport: NodeTransportState::Online,
5326                freshness: NodeFreshness::Fresh,
5327                cursor: Some(NodeCursor { incarnation_id, sequence: 99 }),
5328                inventory: None,
5329                last_attempt_unix_ms: Some(10),
5330                last_success_unix_ms: Some(10),
5331                consecutive_failures: 0,
5332                last_error: None,
5333                gaps: Vec::new(),
5334                gaps_truncated: 0,
5335            };
5336            (node_id, observed)
5337        }).collect();
5338        let topology = C2Topology::from_status(&StatusResponse {
5339            api_version: C2_API_VERSION,
5340            ready: true,
5341            observed_at_unix_ms: 10,
5342            nodes,
5343        });
5344
5345        assert_eq!(topology.nodes.len(), MAX_C2_NODES);
5346        assert_eq!(topology.nodes.first().unwrap().node_id.as_str(), "node-000");
5347        assert_eq!(topology.nodes.last().unwrap().node_id.as_str(), "node-063");
5348        assert_eq!(
5349            topology.nodes[0].current_incarnation_id,
5350            Some(NodeIncarnationId::from_bytes([0; 16])),
5351        );
5352        let json = serde_json::to_string(&C2ServerFrame::Topology(topology)).unwrap();
5353        assert!(!json.contains("observed_at_unix_ms"));
5354        assert!(!json.contains("sequence"));
5355        assert!(!json.contains("inventory"));
5356        assert!(!json.contains("managed_worktrees"));
5357    }
5358
5359    #[test]
5360    fn topology_node_without_relay_route_decodes_as_unknown() {
5361        let legacy = r#"{
5362            "node_id":"node-a",
5363            "endpoint":"legacy-endpoint",
5364            "transport":"offline",
5365            "current_incarnation_id":null
5366        }"#;
5367
5368        let decoded: C2TopologyNode = serde_json::from_str(legacy).unwrap();
5369
5370        assert_eq!(decoded.relay_route, C2RelayRoute::Unknown);
5371        assert!(!serde_json::to_string(&decoded).unwrap().contains("relay_route"));
5372    }
5373
5374    #[test]
5375    fn topology_provider_contract_projection_is_capability_gated_and_change_sensitive() {
5376        let (provider_contracts, provider_adapter_contracts) =
5377            provider_contract_manifest();
5378        let mut inventory = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5379            node_id: NodeId::new("node-a").unwrap(),
5380            enabled_providers: vec![provider("codex")],
5381            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5382            workspaces: Vec::new(),
5383            session_records: Vec::new(),
5384            managed_worktrees: Vec::new(),
5385            launch_inventory: None,
5386            agent_progress: Vec::new(),
5387        });
5388        inventory.provider_contracts = provider_contracts;
5389        inventory.provider_adapter_contracts = provider_adapter_contracts;
5390        let status = StatusResponse {
5391            api_version: C2_API_VERSION,
5392            ready: true,
5393            observed_at_unix_ms: 10,
5394            nodes: BTreeMap::from([(
5395                NodeId::new("node-a").unwrap(),
5396                ObservedNode {
5397                    endpoint: r"\\.\pipe\node-a".to_owned(),
5398                    transport_label: "windows-named-pipe".to_owned(),
5399                    transport: NodeTransportState::Online,
5400                    freshness: NodeFreshness::Fresh,
5401                    cursor: Some(NodeCursor {
5402                        incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5403                        sequence: 9,
5404                    }),
5405                    inventory: Some(inventory),
5406                    last_attempt_unix_ms: Some(10),
5407                    last_success_unix_ms: Some(10),
5408                    consecutive_failures: 0,
5409                    last_error: None,
5410                    gaps: Vec::new(),
5411                    gaps_truncated: 0,
5412                },
5413            )]),
5414        };
5415
5416        let projected = C2Topology::from_status_with_provider_contracts(&status, true);
5417        assert_eq!(projected.nodes[0].provider_contracts.len(), 1);
5418        assert_eq!(projected.nodes[0].provider_adapter_contracts.len(), 1);
5419        let legacy = C2Topology::from_status_with_provider_contracts(&status, false);
5420        assert!(legacy.nodes[0].provider_contracts.is_empty());
5421        assert!(legacy.nodes[0].provider_adapter_contracts.is_empty());
5422        #[derive(Serialize)]
5423        struct LegacyTopologyNode<'a> {
5424            node_id: &'a NodeId,
5425            endpoint: &'a str,
5426            relay_route: C2RelayRoute,
5427            transport: NodeTransportState,
5428            current_incarnation_id: Option<NodeIncarnationId>,
5429        }
5430        #[derive(Serialize)]
5431        struct LegacyTopology<'a> {
5432            nodes: Vec<LegacyTopologyNode<'a>>,
5433        }
5434        let legacy_shape = LegacyTopology {
5435            nodes: legacy.nodes.iter().map(|node| LegacyTopologyNode {
5436                node_id: &node.node_id,
5437                endpoint: &node.endpoint,
5438                relay_route: node.relay_route,
5439                transport: node.transport,
5440                current_incarnation_id: node.current_incarnation_id,
5441            }).collect(),
5442        };
5443        let legacy_json = serde_json::to_string(&legacy).unwrap();
5444        assert_eq!(
5445            serde_json::to_vec(&legacy).unwrap(),
5446            serde_json::to_vec(&legacy_shape).unwrap(),
5447        );
5448        assert!(!legacy_json.contains("provider_contracts"));
5449        assert!(!legacy_json.contains("provider_adapter_contracts"));
5450        assert_ne!(legacy, projected);
5451
5452        let projected_json = serde_json::to_string(&projected).unwrap();
5453        assert!(projected_json.contains("provider_contracts"));
5454        assert!(projected_json.contains("provider_adapter_contracts"));
5455        assert!(!projected_json.contains("inventory"));
5456        assert!(!projected_json.contains("events"));
5457        assert!(!projected_json.contains("routed_response"));
5458    }
5459
5460    #[test]
5461    fn routed_durable_session_responses_round_trip_without_private_record_fields() {
5462        let record = private_session_record();
5463        let session = record.active_session.clone().unwrap();
5464        let native_selection = NativeSessionSelection {
5465            route: NativeSessionCatalogRoute::workspace(
5466                record.workspace_id.clone(),
5467                record.provider.clone(),
5468            ),
5469            catalog_revision: 7,
5470            recent_cutoff_unix_ms: 70,
5471            selection_id: "selection-private".to_owned(),
5472        };
5473        let responses = vec![
5474            NodeResponse::Snapshot {
5475                event_sequence: 4,
5476                controller: None,
5477                snapshot: NodeSnapshot {
5478                    node_id: NodeId::new("node-a").unwrap(),
5479                    enabled_providers: vec![provider("codex")],
5480                    provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5481                    workspaces: Vec::new(),
5482                    session_records: vec![record.clone()],
5483                    managed_worktrees: Vec::new(),
5484                    launch_inventory: None,
5485                    agent_progress: Vec::new(),
5486                },
5487            },
5488            NodeResponse::ProviderSessionIndexed { record: record.clone() },
5489            NodeResponse::NativeSessionIndexed {
5490                selection: native_selection.clone(),
5491                record: record.clone(),
5492            },
5493            NodeResponse::SessionRecordUpdated { record: record.clone() },
5494            NodeResponse::SessionRecordResumed {
5495                record: record.clone(),
5496                session: session.clone(),
5497            },
5498            NodeResponse::Resync {
5499                event_sequence: 5,
5500                oldest_available_sequence: 1,
5501                snapshot: NodeSnapshot {
5502                    node_id: NodeId::new("node-a").unwrap(),
5503                    enabled_providers: vec![provider("codex")],
5504                    provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5505                    workspaces: Vec::new(),
5506                    session_records: vec![record.clone()],
5507                    managed_worktrees: Vec::new(),
5508                    launch_inventory: None,
5509                    agent_progress: Vec::new(),
5510                },
5511                events: vec![gate4agent_node_protocol::NodeEventEnvelope {
5512                    sequence: 5,
5513                    event: NodeEvent::SessionRecordUpserted { record: record.clone() },
5514                }],
5515            },
5516        ];
5517
5518        for response in responses {
5519            let json = serde_json::to_string(&routed_response(response)).unwrap();
5520            assert_private_record_fields_absent(&json);
5521            assert!(json.contains("provider_identity_present"));
5522            assert!(json.contains(r#""profile_id":"local-default""#));
5523            assert!(json.contains(
5524                r#""profile_revision":"local-default.2026-08""#,
5525            ));
5526            let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5527            let decoded_record = match decoded.response.unwrap() {
5528                C2NodeResponse::Snapshot { snapshot, .. } => snapshot.session_records.into_iter().next().unwrap(),
5529                C2NodeResponse::ProviderSessionIndexed { record }
5530                | C2NodeResponse::NativeSessionIndexed { record, .. }
5531                | C2NodeResponse::SessionRecordUpdated { record }
5532                | C2NodeResponse::SessionRecordResumed { record, .. } => record,
5533                C2NodeResponse::Resync { snapshot, events, .. } => {
5534                    assert!(matches!(events.into_iter().next().unwrap().event,
5535                        C2NodeEvent::SessionRecordUpserted { ref record }
5536                        if record.provider_identity_present));
5537                    snapshot.session_records.into_iter().next().unwrap()
5538                }
5539                response => panic!("unexpected response after C2 round trip: {response:?}"),
5540            };
5541            assert_eq!(decoded_record.record_id.as_str(), "session-private");
5542            assert_eq!(decoded_record.display_name, "release shepherd");
5543            assert_eq!(decoded_record.active_session.as_ref(), Some(&session));
5544            assert!(decoded_record.provider_identity_present);
5545        }
5546    }
5547
5548    #[test]
5549    fn c2_resync_projection_preserves_authoritative_replay_floor() {
5550        let source = NodeResponse::Resync {
5551            event_sequence: 12,
5552            oldest_available_sequence: 9,
5553            snapshot: NodeSnapshot {
5554                node_id: NodeId::new("node-a").unwrap(),
5555                enabled_providers: Vec::new(),
5556                provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5557                workspaces: Vec::new(),
5558                session_records: Vec::new(),
5559                managed_worktrees: Vec::new(),
5560                launch_inventory: None,
5561                agent_progress: Vec::new(),
5562            },
5563            events: Vec::new(),
5564        };
5565        let projected = C2NodeResponse::from(&source);
5566        assert!(matches!(
5567            projected,
5568            C2NodeResponse::Resync {
5569                event_sequence: 12,
5570                oldest_available_sequence: 9,
5571                ..
5572            }
5573        ));
5574        let encoded = serde_json::to_vec(&projected).unwrap();
5575        assert_eq!(
5576            serde_json::from_slice::<C2NodeResponse>(&encoded).unwrap(),
5577            projected,
5578        );
5579    }
5580
5581    #[test]
5582    fn routed_session_record_event_round_trips_without_private_record_fields() {
5583        let routed = RoutedNodeEvent {
5584            node_id: NodeId::new("node-a").unwrap(),
5585            cursor: NodeCursor {
5586                incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
5587                sequence: 9,
5588            },
5589            event: C2NodeEvent::from(&NodeEvent::SessionRecordUpserted {
5590                record: private_session_record(),
5591            }),
5592        };
5593
5594        let json = serde_json::to_string(&routed).unwrap();
5595        assert_private_record_fields_absent(&json);
5596        assert!(json.contains("provider_identity_present"));
5597        let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
5598        let C2NodeEvent::SessionRecordUpserted { record } = decoded.event else {
5599            panic!("unexpected routed event after C2 round trip");
5600        };
5601        assert_eq!(record.record_id.as_str(), "session-private");
5602        assert_eq!(record.display_name, "release shepherd");
5603        assert!(record.provider_identity_present);
5604    }
5605
5606    #[test]
5607    fn routed_node_failure_replaces_raw_message_with_fixed_category() {
5608        let raw = NodeFailure {
5609            code: NodeFailureCode::BackendOperationFailed,
5610            message: r"provider token-secret failed at C:\private\relay.log".to_owned(),
5611        };
5612        let routed = RoutedNodeResponse {
5613            node_id: NodeId::new("node-a").unwrap(),
5614            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5615            response: Err(C2NodeFailure::from(&raw)),
5616        };
5617
5618        let json = serde_json::to_string(&routed).unwrap();
5619        assert!(!json.contains("token-secret"));
5620        assert!(!json.contains("private"));
5621        assert!(!json.contains("relay.log"));
5622        let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5623        assert_eq!(decoded.response.unwrap_err(), C2NodeFailure {
5624            code: NodeFailureCode::BackendOperationFailed,
5625            message: "node backend operation failed".to_owned(),
5626        });
5627    }
5628
5629    #[test]
5630    fn routed_workspace_inspection_omits_raw_git_diagnostics_and_reasons() {
5631        let worktree = GitWorktreeSnapshot {
5632            path: host_path(r"C:\work\feature"),
5633            head: "abc123".to_owned(),
5634            branch: Some("feature/privacy".to_owned()),
5635            is_bare: false,
5636            is_main: false,
5637            locked: true,
5638            lock_reason: Some("lock-secret-provider-token".to_owned()),
5639            prunable: true,
5640            prunable_reason: Some("prunable-secret-private-path".to_owned()),
5641            workspace_id: Some(WorkspaceId::new("feature").unwrap()),
5642        };
5643        let response = routed_response(NodeResponse::WorkspaceInspected {
5644            inspection: WorkspaceInspection {
5645                workspace_id: WorkspaceId::new("primary").unwrap(),
5646                entries: Vec::new(),
5647                tree_truncated: false,
5648                git: GitSnapshot {
5649                    is_repository: true,
5650                    branch: Some("main".to_owned()),
5651                    status: Vec::new(),
5652                    recent_commits: Vec::new(),
5653                    worktrees: vec![worktree],
5654                    managed_worktree: None,
5655                    truncated: false,
5656                    diagnostic: Some("diagnostic-secret C:\\private\\git.stderr".to_owned()),
5657                },
5658                truncation: None,
5659            },
5660        });
5661
5662        let json = serde_json::to_string(&response).unwrap();
5663        for secret in [
5664            "lock-secret-provider-token",
5665            "prunable-secret-private-path",
5666            "diagnostic-secret",
5667            "git.stderr",
5668        ] {
5669            assert!(!json.contains(secret));
5670        }
5671        assert!(!json.contains("lock_reason"));
5672        assert!(!json.contains("prunable_reason"));
5673        assert!(!json.contains("\"diagnostic\":"));
5674        let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5675        let Ok(C2NodeResponse::WorkspaceInspected { inspection }) = decoded.response else {
5676            panic!("unexpected routed workspace response");
5677        };
5678        assert!(inspection.git.diagnostic_present);
5679        assert!(inspection.git.worktrees[0].locked);
5680        assert!(inspection.git.worktrees[0].prunable);
5681        assert_eq!(inspection.git.worktrees[0].path.display_text(), r"C:\work\feature");
5682    }
5683
5684    #[test]
5685    fn routed_workspace_file_read_projects_only_correlated_operator_content() {
5686        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
5687        let response = routed_response(NodeResponse::WorkspaceFileRead {
5688            file: WorkspaceFileRead {
5689                workspace_id: WorkspaceId::new("primary").unwrap(),
5690                path: path.clone(),
5691                content: WorkspaceFileContent::Utf8 {
5692                    text: "pub fn fixture() {}\n".to_owned(),
5693                    byte_len: 20,
5694                },
5695                revision: None,
5696            },
5697        });
5698
5699        let json = serde_json::to_string(&response).unwrap();
5700        assert!(!json.contains("canonical_root"));
5701        assert!(!json.contains("diagnostic"));
5702        assert!(!json.contains("controller"));
5703        assert!(!json.contains("inventory"));
5704        assert!(!json.contains("event_sequence"));
5705        let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5706        assert_eq!(decoded.response, Ok(C2NodeResponse::WorkspaceFileRead {
5707            file: WorkspaceFileRead {
5708                workspace_id: WorkspaceId::new("primary").unwrap(),
5709                path,
5710                content: WorkspaceFileContent::Utf8 {
5711                    text: "pub fn fixture() {}\n".to_owned(),
5712                    byte_len: 20,
5713                },
5714                revision: None,
5715            },
5716        }));
5717    }
5718
5719    #[test]
5720    fn workspace_entry_create_projects_exact_bounded_c2_contracts() {
5721        assert_eq!(
5722            C2_WORKSPACE_ENTRY_CREATE_CAPABILITY,
5723            "workspace-entry-create-v1",
5724        );
5725        let workspace_id = WorkspaceId::new("primary").unwrap();
5726        let file = WorkspaceFileRead {
5727            workspace_id: workspace_id.clone(),
5728            path: repository_path("src/new.rs"),
5729            content: WorkspaceFileContent::Utf8 {
5730                text: String::new(),
5731                byte_len: 0,
5732            },
5733            revision: Some(
5734                gate4agent_node_protocol::WorkspaceFileRevision::new(
5735                    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
5736                        .to_owned(),
5737                )
5738                .unwrap(),
5739            ),
5740        };
5741        let file_response = C2NodeResponse::from(
5742            &NodeResponse::WorkspaceFileCreated { file: file.clone() },
5743        );
5744        assert_eq!(
5745            file_response,
5746            C2NodeResponse::WorkspaceFileCreated { file },
5747        );
5748        assert!(file_response.requires_workspace_entry_create_capability());
5749
5750        let entry = WorkspaceEntry {
5751            relative_path: repository_path("src/new"),
5752            kind: WorkspaceEntryKind::Directory,
5753        };
5754        let directory_response = C2NodeResponse::from(
5755            &NodeResponse::WorkspaceDirectoryCreated {
5756                workspace_id: workspace_id.clone(),
5757                entry: entry.clone(),
5758            },
5759        );
5760        assert_eq!(
5761            directory_response,
5762            C2NodeResponse::WorkspaceDirectoryCreated {
5763                workspace_id,
5764                entry,
5765            },
5766        );
5767        assert!(directory_response.requires_workspace_entry_create_capability());
5768        let encoded = serde_json::to_string(&directory_response).unwrap();
5769        assert_eq!(
5770            serde_json::from_str::<C2NodeResponse>(&encoded).unwrap(),
5771            directory_response,
5772        );
5773
5774        for (code, message) in [
5775            (NodeFailureCode::RepositoryEntryAlreadyExists, "repository entry already exists"),
5776            (NodeFailureCode::RepositoryParentNotFound, "repository parent directory unavailable"),
5777            (NodeFailureCode::RepositoryParentNotDirectory, "repository parent path is not a directory"),
5778            (NodeFailureCode::RepositoryEntryCreateTimedOut, "repository entry creation timed out"),
5779            (NodeFailureCode::RepositoryEntryCreateFailed, "repository entry creation failed"),
5780        ] {
5781            assert_eq!(
5782                C2NodeFailure::from(&NodeFailure {
5783                    code,
5784                    message: "private node detail".to_owned(),
5785                })
5786                .message,
5787                message,
5788            );
5789        }
5790    }
5791
5792    #[test]
5793    fn c2_host_directory_browse_projection_is_bounded_and_sanitizes_failures() {
5794        assert_eq!(C2_HOST_DIRECTORY_BROWSE_CAPABILITY, "host-directory-browse-v1");
5795        let directory = host_path(r"C:\Users");
5796        let entry = HostDirectoryEntry {
5797            path: host_path(r"C:\Users\Public"),
5798            display_name: "Public".to_owned(),
5799            is_link: false,
5800        };
5801        let response = routed_response(NodeResponse::HostDirectoriesBrowsed {
5802            listing: HostDirectoryListing {
5803                directory: Some(directory.clone()),
5804                parent: Some(host_path(r"C:\")),
5805                entries: vec![entry.clone()],
5806                next_after: Some(entry.path.clone()),
5807                incomplete: true,
5808            },
5809        });
5810        let json = serde_json::to_string(&response).unwrap();
5811        let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5812        assert_eq!(decoded, response);
5813        assert!(matches!(
5814            decoded.response,
5815            Ok(C2NodeResponse::HostDirectoriesBrowsed {
5816                listing: HostDirectoryListing {
5817                    directory: Some(ref actual),
5818                    incomplete: true,
5819                    ..
5820                }
5821            }) if actual == &directory
5822        ));
5823
5824        let mut oversized = serde_json::to_value(C2NodeResponse::HostDirectoriesBrowsed {
5825            listing: HostDirectoryListing {
5826                directory: None,
5827                parent: None,
5828                entries: vec![entry; gate4agent_node_protocol::MAX_HOST_DIRECTORY_ENTRIES + 1],
5829                next_after: None,
5830                incomplete: false,
5831            },
5832        }).unwrap();
5833        assert!(serde_json::from_value::<C2NodeResponse>(oversized.take()).is_err());
5834
5835        for (code, expected) in [
5836            (NodeFailureCode::HostDirectoryInvalid, "host directory invalid"),
5837            (NodeFailureCode::HostDirectoryReadFailed, "host directory read failed"),
5838            (NodeFailureCode::HostDirectoryReadTimedOut, "host directory read timed out"),
5839        ] {
5840            let failure = C2NodeFailure::from(&NodeFailure {
5841                code,
5842                message: r"private C:\Users\owner\secret".to_owned(),
5843            });
5844            assert_eq!(failure.message, expected);
5845            assert!(failure.requires_host_directory_browse_capability());
5846            assert!(!failure.message.contains("private"));
5847        }
5848    }
5849
5850    #[test]
5851    fn c2_projection_roundtrips_non_utf8_host_paths_without_interpretation() {
5852        let opaque = OpaqueHostPath::unix_bytes(vec![b'/', b's', b'r', b'v', b'/', 0xff, b'\n', 0x1b]).unwrap();
5853        let projected = C2NodeSnapshot::from(&NodeSnapshot {
5854            node_id: NodeId::new("remote-linux").unwrap(),
5855            enabled_providers: Vec::new(),
5856            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5857            workspaces: vec![WorkspaceSnapshot {
5858                workspace_id: WorkspaceId::new("repo").unwrap(),
5859                canonical_root: opaque.clone(),
5860                sessions: Vec::new(),
5861                worktree_service_mode: None,
5862                managed_worktree_profiles: None,
5863            }],
5864            session_records: Vec::new(),
5865            managed_worktrees: Vec::new(),
5866            launch_inventory: None,
5867            agent_progress: Vec::new(),
5868        });
5869
5870        let encoded = serde_json::to_string(&projected).unwrap();
5871        let decoded = serde_json::from_str::<C2NodeSnapshot>(&encoded).unwrap();
5872
5873        assert_eq!(decoded.workspaces[0].canonical_root, opaque);
5874        assert_eq!(decoded.workspaces[0].workspace_id.as_str(), "repo");
5875        let slim = SlimNodeInventory::from_c2_snapshot(&decoded);
5876        let display = &slim.workspaces[&WorkspaceId::new("repo").unwrap()].canonical_root;
5877        assert!(!display.chars().any(char::is_control));
5878        assert!(display.contains("\\n"));
5879        assert!(display.contains("\\u{1b}"));
5880    }
5881
5882    #[test]
5883    fn standalone_workspace_response_roundtrips_authoritative_snapshot() {
5884        assert_eq!(
5885            C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
5886            NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
5887        );
5888        let workspace = WorkspaceSnapshot {
5889            workspace_id: WorkspaceId::new("standalone").unwrap(),
5890            canonical_root: host_path(r"C:\standalone"),
5891            sessions: Vec::new(),
5892            worktree_service_mode: Some(WorktreeServiceMode::Manual),
5893            managed_worktree_profiles: Some(WorktreeProfileInventory {
5894                profiles: Vec::new(),
5895            }),
5896        };
5897        let projected = C2NodeResponse::from(
5898            &NodeResponse::StandaloneWorkspaceCreated {
5899                workspace: workspace.clone(),
5900            },
5901        );
5902        let encoded = serde_json::to_string(&projected).unwrap();
5903        let decoded = serde_json::from_str::<C2NodeResponse>(&encoded).unwrap();
5904
5905        assert_eq!(
5906            decoded,
5907            C2NodeResponse::StandaloneWorkspaceCreated {
5908                workspace: C2WorkspaceSnapshot::from(&workspace),
5909            },
5910        );
5911    }
5912
5913    #[test]
5914    fn routed_worktree_created_omits_raw_git_reasons() {
5915        let response = routed_response(NodeResponse::WorktreeCreated {
5916            worktree: GitWorktreeSnapshot {
5917                path: host_path(r"C:\work\feature"),
5918                head: "abc123".to_owned(),
5919                branch: Some("feature/privacy".to_owned()),
5920                is_bare: false,
5921                is_main: false,
5922                locked: true,
5923                lock_reason: Some("created-lock-secret".to_owned()),
5924                prunable: true,
5925                prunable_reason: Some("created-prunable-secret".to_owned()),
5926                workspace_id: Some(WorkspaceId::new("feature").unwrap()),
5927            },
5928            workspace: WorkspaceSnapshot {
5929                workspace_id: WorkspaceId::new("feature").unwrap(),
5930                canonical_root: host_path(r"C:\work\feature"),
5931                sessions: Vec::new(),
5932                worktree_service_mode: None,
5933                managed_worktree_profiles: None,
5934            },
5935        });
5936
5937        let json = serde_json::to_string(&response).unwrap();
5938        assert!(!json.contains("created-lock-secret"));
5939        assert!(!json.contains("created-prunable-secret"));
5940        assert!(!json.contains("lock_reason"));
5941        assert!(!json.contains("prunable_reason"));
5942        let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5943        assert!(matches!(decoded.response, Ok(C2NodeResponse::WorktreeCreated {
5944            worktree: C2GitWorktreeSnapshot { locked: true, prunable: true, .. },
5945            ..
5946        })));
5947    }
5948
5949    #[test]
5950    fn routed_resume_authorized_control_event_omits_provider_session_identity() {
5951        let routed = routed_control_event(ControlEventKind::ResumeAuthorized {
5952            session: ResumeSessionSummary {
5953                key: ProviderSessionKey::SessionId,
5954                id: "private-resume-session-id".to_owned(),
5955            },
5956        });
5957
5958        let json = serde_json::to_string(&routed).unwrap();
5959        assert!(json.contains("resume-authorized"));
5960        assert!(!json.contains("private-resume-session-id"));
5961        assert!(!json.contains("provider_session"));
5962        assert!(!json.contains("transcript_path"));
5963        let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
5964        assert!(matches!(decoded.event, C2NodeEvent::Control {
5965            event: C2ControlEvent { event: C2ControlEventKind::ResumeAuthorized, .. },
5966            ..
5967        }));
5968    }
5969
5970    #[test]
5971    fn routed_session_identity_observed_control_event_omits_provider_identity_and_path() {
5972        let routed = routed_control_event(ControlEventKind::ProviderEvent {
5973            sequence: 19,
5974            source: provider_source(),
5975            source_sequence: 7,
5976            event: ProviderEvent::SessionIdentityObserved {
5977                identity: ProviderSessionIdentity {
5978                    key: ProviderSessionKey::ConversationId,
5979                    id: "private-observed-provider-id".to_owned(),
5980                    transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
5981                },
5982            },
5983        });
5984
5985        let json = serde_json::to_string(&routed).unwrap();
5986        assert!(json.contains("session-identity-observed"));
5987        assert!(!json.contains("private-observed-provider-id"));
5988        assert!(!json.contains("provider-transcript.jsonl"));
5989        assert!(!json.contains("transcript_path"));
5990        assert!(!json.contains("\"identity\":"));
5991        let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
5992        assert!(matches!(decoded.event, C2NodeEvent::Control {
5993            event: C2ControlEvent {
5994                event: C2ControlEventKind::ProviderEvent {
5995                    event: C2ProviderEventKind::SessionIdentityObserved,
5996                },
5997                ..
5998            },
5999            ..
6000        }));
6001    }
6002
6003    #[test]
6004    fn routed_provider_interaction_resolution_is_categorical_only() {
6005        let routed = routed_control_event(ControlEventKind::ProviderEvent {
6006            sequence: 20,
6007            source: provider_source(),
6008            source_sequence: 8,
6009            event: ProviderEvent::InteractionResolved {
6010                request_id: "private-provider-request-id".to_owned(),
6011                outcome: ProviderInteractionOutcome::Denied,
6012            },
6013        });
6014
6015        let json = serde_json::to_string(&routed).unwrap();
6016        assert!(json.contains("interaction-resolved"));
6017        assert!(!json.contains("private-provider-request-id"));
6018        assert!(!json.contains("denied"));
6019        let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6020        assert!(matches!(decoded.event, C2NodeEvent::Control {
6021            event: C2ControlEvent {
6022                event: C2ControlEventKind::ProviderEvent {
6023                    event: C2ProviderEventKind::InteractionResolved,
6024                },
6025                ..
6026            },
6027            ..
6028        }));
6029    }
6030
6031    #[test]
6032    fn routed_snapshot_recursively_omits_provider_identity_and_error_state() {
6033        let mut session = fixture_session();
6034        session.status = SessionStatus::Failed {
6035            message: "private-session-failure".to_owned(),
6036        };
6037        session.terminal_stale = Some("private-terminal-error".to_owned());
6038        session.history.last_error = Some("private-history-error".to_owned());
6039        session.resume.last_session = Some(ResumeSessionSummary {
6040            key: ProviderSessionKey::SessionId,
6041            id: "private-resume-summary-id".to_owned(),
6042        });
6043        session.resume.last_error = Some("private-resume-error".to_owned());
6044        session.foreground.stale_reason = Some("private-foreground-error".to_owned());
6045        session.provider.session = Some(ProviderSessionIdentity {
6046            key: ProviderSessionKey::SessionId,
6047            id: "private-snapshot-provider-id".to_owned(),
6048            transcript_path: Some(r"C:\private\snapshot-transcript.jsonl".to_owned()),
6049        });
6050        session.provider.current_prompt = Some("private-current-prompt".to_owned());
6051        session.provider.last_event = Some(ProviderEvent::Error {
6052            message: "private-provider-error".to_owned(),
6053        });
6054        let routed = routed_response(NodeResponse::Snapshot {
6055            event_sequence: 4,
6056            controller: None,
6057            snapshot: NodeSnapshot {
6058                node_id: NodeId::new("node-a").unwrap(),
6059                enabled_providers: vec![provider("codex")],
6060                provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6061                workspaces: vec![WorkspaceSnapshot {
6062                    workspace_id: WorkspaceId::new("primary").unwrap(),
6063                    canonical_root: host_path(r"C:\workspace"),
6064                    sessions: vec![session],
6065                    worktree_service_mode: None,
6066                    managed_worktree_profiles: None,
6067                }],
6068                session_records: Vec::new(),
6069                managed_worktrees: Vec::new(),
6070                launch_inventory: None,
6071                agent_progress: Vec::new(),
6072            },
6073        });
6074
6075        let json = serde_json::to_string(&routed).unwrap();
6076        for secret in [
6077            "private-session-failure",
6078            "private-terminal-error",
6079            "private-history-error",
6080            "private-resume-summary-id",
6081            "private-resume-error",
6082            "private-foreground-error",
6083            "private-snapshot-provider-id",
6084            "snapshot-transcript.jsonl",
6085            "private-current-prompt",
6086            "private-provider-error",
6087        ] {
6088            assert!(!json.contains(secret));
6089        }
6090        assert!(!json.contains("provider_session"));
6091        assert!(!json.contains("transcript_path"));
6092        assert!(!json.contains("last_error"));
6093        let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
6094        assert!(matches!(decoded.response, Ok(C2NodeResponse::Snapshot { snapshot, .. })
6095            if matches!(snapshot.workspaces[0].sessions[0].status, C2SessionStatus::Failed)
6096                && snapshot.workspaces[0].sessions[0].provider_identity_present));
6097    }
6098
6099    #[test]
6100    fn slim_managed_sessions_are_sorted_bounded_and_privacy_minimized() {
6101        let long_name = "ж".repeat((MAX_C2_SESSION_DISPLAY_NAME_BYTES / 2) + 4);
6102        let make_record = |record_id: &str, display_name: String| ManagedSessionRecord {
6103            record_id: SessionRecordId::new(record_id).unwrap(),
6104            display_name,
6105            provider: provider("codex"),
6106            mode: SessionMode::Pty,
6107            state: ManagedSessionState::Dormant,
6108            workspace_id: WorkspaceId::new("primary").unwrap(),
6109            canonical_root: host_path(r"C:\private\workspace"),
6110            provider_session: Some(ProviderSessionIdentity {
6111                key: ProviderSessionKey::SessionId,
6112                id: "5af75a6b-3e64-41dd-96fa-private-provider-id".to_owned(),
6113                transcript_path: Some(r"C:\private\transcript.jsonl".to_owned()),
6114            }),
6115            active_session: None,
6116            environment_profile: None,
6117            bundle: None,
6118            context_id: None,
6119            context: None,
6120            exported_context: None,
6121            task_binding: None,
6122            created_at_unix_ms: 10,
6123            updated_at_unix_ms: 20,
6124            last_error: Some("private backend diagnostic".to_owned()),
6125        };
6126        let snapshot = NodeSnapshot {
6127            node_id: NodeId::new("node-a").unwrap(),
6128            enabled_providers: vec![provider("codex")],
6129            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6130            workspaces: Vec::new(),
6131            session_records: vec![
6132                make_record("session-z", "z".to_owned()),
6133                make_record("session-a", long_name),
6134            ],
6135            managed_worktrees: Vec::new(),
6136            launch_inventory: None,
6137            agent_progress: Vec::new(),
6138        };
6139
6140        let slim = SlimNodeInventory::from_snapshot(&snapshot);
6141        assert_eq!(slim.managed_session_count, 2);
6142        assert!(!slim.managed_sessions_truncated);
6143        assert_eq!(
6144            slim.managed_sessions.iter().map(|record| record.record_id.as_str()).collect::<Vec<_>>(),
6145            vec!["session-a", "session-z"],
6146        );
6147        assert_eq!(slim.managed_sessions[0].display_name.len(), MAX_C2_SESSION_DISPLAY_NAME_BYTES);
6148        assert!(slim.managed_sessions[0].display_name_truncated);
6149        assert!(slim.managed_sessions[0].provider_identity_present);
6150
6151        let json = serde_json::to_string(&slim).unwrap();
6152        assert!(!json.contains("5af75a6b-3e64-41dd-96fa-private-provider-id"));
6153        assert!(!json.contains("transcript.jsonl"));
6154        assert!(!json.contains("private backend diagnostic"));
6155        assert!(!json.contains("private\\\\workspace"));
6156        assert!(!json.contains("provider_session"));
6157        assert!(!json.contains("last_error"));
6158    }
6159
6160    #[test]
6161    fn slim_inventory_defaults_managed_sessions_for_v1_payloads() {
6162        let legacy = r#"{"node_id":"node-a","enabled_providers":[],"workspaces":{},"workspace_count":0,"workspaces_truncated":false,"session_count":0,"sessions_truncated":false}"#;
6163        let inventory = serde_json::from_str::<SlimNodeInventory>(legacy).unwrap();
6164        assert!(inventory.provider_contracts.is_empty());
6165        assert!(inventory.provider_adapter_contracts.is_empty());
6166        assert!(inventory.managed_sessions.is_empty());
6167        assert_eq!(inventory.managed_session_count, 0);
6168        assert!(!inventory.managed_sessions_truncated);
6169        let reencoded = serde_json::to_string(&inventory).unwrap();
6170        assert!(!reencoded.contains("provider_contracts"));
6171        assert!(!reencoded.contains("provider_adapter_contracts"));
6172        assert_eq!(inventory.retired_count, 0);
6173        assert!(!reencoded.contains("retired_count"));
6174    }
6175
6176    /// Slice R (`gate4agent-node`'s session-record retention sweep):
6177    /// `retired_count` is additive, defaults to `0` for a pre-existing
6178    /// payload that never had it, and round-trips intact once a producer
6179    /// sets a real, nonzero value.
6180    #[test]
6181    fn slim_inventory_retired_count_round_trips_and_defaults_to_zero() {
6182        let legacy = r#"{"node_id":"node-a","enabled_providers":[],"workspaces":{},"workspace_count":0,"workspaces_truncated":false,"session_count":0,"sessions_truncated":false}"#;
6183        let inventory = serde_json::from_str::<SlimNodeInventory>(legacy).unwrap();
6184        assert_eq!(inventory.retired_count, 0);
6185
6186        let mut inventory = inventory;
6187        inventory.retired_count = 7;
6188        let encoded = serde_json::to_string(&inventory).unwrap();
6189        assert!(encoded.contains("\"retired_count\":7"));
6190        let decoded: SlimNodeInventory = serde_json::from_str(&encoded).unwrap();
6191        assert_eq!(decoded.retired_count, 7);
6192    }
6193
6194    #[test]
6195    fn slim_inventory_reports_managed_session_truncation() {
6196        let session_records = (0..=MAX_C2_MANAGED_SESSIONS_PER_NODE)
6197            .map(|index| ManagedSessionRecord {
6198                record_id: SessionRecordId::new(format!("session-{index:03}")).unwrap(),
6199                display_name: format!("session {index}"),
6200                provider: provider("claude"),
6201                mode: SessionMode::Inline,
6202                state: ManagedSessionState::Unavailable,
6203                workspace_id: WorkspaceId::new("primary").unwrap(),
6204                canonical_root: host_path(r"C:\repo"),
6205                provider_session: None,
6206                active_session: None,
6207                environment_profile: None,
6208                bundle: None,
6209                context_id: None,
6210                context: None,
6211                exported_context: None,
6212                task_binding: None,
6213                created_at_unix_ms: index as u64,
6214                updated_at_unix_ms: index as u64,
6215                last_error: None,
6216            })
6217            .collect::<Vec<_>>();
6218        let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
6219            node_id: NodeId::new("node-a").unwrap(),
6220            enabled_providers: Vec::new(),
6221            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6222            workspaces: Vec::new(),
6223            session_records,
6224            managed_worktrees: Vec::new(),
6225            launch_inventory: None,
6226            agent_progress: Vec::new(),
6227        });
6228        assert_eq!(slim.managed_session_count, MAX_C2_MANAGED_SESSIONS_PER_NODE + 1);
6229        assert_eq!(slim.managed_sessions.len(), MAX_C2_MANAGED_SESSIONS_PER_NODE);
6230        assert!(slim.managed_sessions_truncated);
6231    }
6232
6233    #[test]
6234    fn slim_managed_sessions_page_keeps_the_live_record_even_when_it_sorts_last_by_id() {
6235        // 130 stale `Unavailable` records (ids sort ahead of both records
6236        // below) plus one `Live` record and one `IdentityPending` record
6237        // whose ids sort LAST of all -- the `IdentityPending` one sorts last
6238        // by id even among the `Unavailable` records. An id-only sort
6239        // followed by `.take(MAX_C2_MANAGED_SESSIONS_PER_NODE)` would cut
6240        // both the live record and the just-spawned, identity-pending one
6241        // from the page; liveness-first ordering must keep both in,
6242        // `IdentityPending` right behind `Live` and ahead of every
6243        // `Unavailable` record, regardless of where their ids fall.
6244        let stale_count = MAX_C2_MANAGED_SESSIONS_PER_NODE + 2;
6245        let mut session_records = (0..stale_count)
6246            .map(|index| ManagedSessionRecord {
6247                record_id: SessionRecordId::new(format!("session-{index:03}")).unwrap(),
6248                display_name: format!("session {index}"),
6249                provider: provider("claude"),
6250                mode: SessionMode::Inline,
6251                state: ManagedSessionState::Unavailable,
6252                workspace_id: WorkspaceId::new("primary").unwrap(),
6253                canonical_root: host_path(r"C:\repo"),
6254                provider_session: None,
6255                active_session: None,
6256                environment_profile: None,
6257                bundle: None,
6258                context_id: None,
6259                context: None,
6260                exported_context: None,
6261                task_binding: None,
6262                created_at_unix_ms: index as u64,
6263                updated_at_unix_ms: index as u64,
6264                last_error: None,
6265            })
6266            .collect::<Vec<_>>();
6267        session_records.push(ManagedSessionRecord {
6268            record_id: SessionRecordId::new(format!("session-{stale_count:03}")).unwrap(),
6269            display_name: "live-run".to_owned(),
6270            provider: provider("claude"),
6271            mode: SessionMode::Pty,
6272            state: ManagedSessionState::Live,
6273            workspace_id: WorkspaceId::new("primary").unwrap(),
6274            canonical_root: host_path(r"C:\repo"),
6275            provider_session: None,
6276            active_session: None,
6277            environment_profile: None,
6278            bundle: None,
6279            context_id: None,
6280            context: None,
6281            exported_context: None,
6282            task_binding: None,
6283            created_at_unix_ms: stale_count as u64,
6284            updated_at_unix_ms: stale_count as u64,
6285            last_error: None,
6286        });
6287        let pending_index = stale_count + 1;
6288        session_records.push(ManagedSessionRecord {
6289            record_id: SessionRecordId::new(format!("session-{pending_index:03}")).unwrap(),
6290            display_name: "pending-spawn".to_owned(),
6291            provider: provider("claude"),
6292            mode: SessionMode::Pty,
6293            state: ManagedSessionState::IdentityPending,
6294            workspace_id: WorkspaceId::new("primary").unwrap(),
6295            canonical_root: host_path(r"C:\repo"),
6296            provider_session: None,
6297            active_session: None,
6298            environment_profile: None,
6299            bundle: None,
6300            context_id: None,
6301            context: None,
6302            exported_context: None,
6303            task_binding: None,
6304            created_at_unix_ms: pending_index as u64,
6305            updated_at_unix_ms: pending_index as u64,
6306            last_error: None,
6307        });
6308        let total = session_records.len();
6309        assert!(total > MAX_C2_MANAGED_SESSIONS_PER_NODE);
6310
6311        let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
6312            node_id: NodeId::new("node-a").unwrap(),
6313            enabled_providers: Vec::new(),
6314            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6315            workspaces: Vec::new(),
6316            session_records,
6317            managed_worktrees: Vec::new(),
6318            launch_inventory: None,
6319            agent_progress: Vec::new(),
6320        });
6321
6322        assert_eq!(slim.managed_session_count, total);
6323        assert!(slim.managed_sessions_truncated);
6324        assert_eq!(slim.managed_sessions.len(), MAX_C2_MANAGED_SESSIONS_PER_NODE);
6325        assert!(
6326            slim.managed_sessions.iter().any(|record| record.state == ManagedSessionState::Live),
6327            "the live record must survive the page cut even though its id sorts last",
6328        );
6329        assert!(
6330            slim.managed_sessions.iter()
6331                .any(|record| record.state == ManagedSessionState::IdentityPending),
6332            "the identity-pending record must survive the page cut even though its id sorts last",
6333        );
6334        assert_eq!(slim.managed_sessions[0].state, ManagedSessionState::Live);
6335        assert_eq!(slim.managed_sessions[1].state, ManagedSessionState::IdentityPending);
6336        assert!(
6337            slim.managed_sessions[2..].iter().all(|record| record.state == ManagedSessionState::Unavailable),
6338            "every Unavailable record on the page must rank after Live and IdentityPending",
6339        );
6340    }
6341
6342    #[test]
6343    fn managed_worktree_projection_is_bounded_and_contains_no_git_or_path_details() {
6344        fn lease(index: usize) -> ManagedWorktreeLeaseSnapshot {
6345            ManagedWorktreeLeaseSnapshot {
6346                lease_id: ManagedWorktreeLeaseId::new(format!("lease-{index}")).unwrap(),
6347                source_workspace_id: WorkspaceId::new("primary").unwrap(),
6348                workspace_id: WorkspaceId::new(format!("managed-{index}")).unwrap(),
6349                profile_id: WorktreeProfileId::new("review").unwrap(),
6350                profile_revision: WorktreeProfileRevision::new("review.r1").unwrap(),
6351                retention: ManagedWorktreeRetention::RemoveWhenReleased,
6352                state: ManagedWorktreeLeaseState::Ready,
6353                active_session_count: 0,
6354                managed_record_count: 0,
6355                cleanup_failure: None,
6356                created_at_unix_ms: 1,
6357                updated_at_unix_ms: 2,
6358            }
6359        }
6360
6361        let projected = C2NodeSnapshot::from(&NodeSnapshot {
6362            node_id: NodeId::new("node-a").unwrap(),
6363            enabled_providers: Vec::new(),
6364            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6365            workspaces: Vec::new(),
6366            session_records: Vec::new(),
6367            managed_worktrees: vec![lease(0)],
6368            launch_inventory: None,
6369            agent_progress: Vec::new(),
6370        });
6371        let json = serde_json::to_string(&projected).unwrap();
6372        assert!(json.contains("managed_worktrees"));
6373        for forbidden in ["canonical", "path", "gitdir", "branch", "base_commit", "diagnostic"] {
6374            assert!(!json.contains(forbidden), "managed projection leaked {forbidden}");
6375        }
6376
6377        let overflow = C2NodeSnapshot {
6378            node_id: NodeId::new("node-a").unwrap(),
6379            enabled_providers: Vec::new(),
6380            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6381            workspaces: Vec::new(),
6382            session_records: Vec::new(),
6383            agent_progress: Vec::new(),
6384            managed_worktrees: (0..=MAX_C2_MANAGED_WORKTREES_PER_NODE)
6385                .map(lease)
6386                .collect(),
6387            launch_inventory: None,
6388        };
6389        let encoded = serde_json::to_value(overflow).unwrap();
6390        assert!(serde_json::from_value::<C2NodeSnapshot>(encoded).is_err());
6391    }
6392}