Skip to main content

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