Skip to main content

hatchery_core/
lib.rs

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