1use crate::{
4 AgentId, KitId, PlanId, PolicyId, ProfileId, ProjectId, ProtocolReference, ReceiptId, RunId,
5 SemanticVersion, SessionId, Sha256Digest, TaskId, TenantId, TraceId, UtcTimestamp,
6 V1_SCHEMA_VERSION, ValidationError, WorkspaceId, deserialize_schema_version,
7 validate_schema_version,
8};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct SessionIdentityV1 {
16 pub session_id: SessionId,
17 pub task_id: TaskId,
18 pub run_id: RunId,
19 pub trace_id: TraceId,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub parent_task_id: Option<TaskId>,
22 pub agent_id: AgentId,
23 pub project_id: ProjectId,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub workspace_id: Option<WorkspaceId>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub tenant_id: Option<TenantId>,
28 pub project_root_ref: ProtocolReference,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub project_revision_ref: Option<ProtocolReference>,
31 pub created_at: UtcTimestamp,
32}
33
34impl SessionIdentityV1 {
35 pub fn validate(&self) -> Result<(), ValidationError> {
37 if self.parent_task_id.as_ref() == Some(&self.task_id) {
38 return Err(ValidationError::new(
39 "SessionIdentityV1 parent_task_id must not equal task_id",
40 ));
41 }
42 Ok(())
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct TuningProfilePinV1 {
50 pub profile_id: ProfileId,
51 pub version: SemanticVersion,
52 pub content_digest: Sha256Digest,
53 pub source_ref: ProtocolReference,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ContextKitPinV1 {
60 pub kit_id: KitId,
61 pub version: SemanticVersion,
62 pub package_digest: Sha256Digest,
63 pub activation_ref: ProtocolReference,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ContextSdkIntegrationDepthV1 {
70 Attach,
71 Wrap,
72 Embed,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct ContextSessionConfigurationV1 {
79 pub tuning_profile: TuningProfilePinV1,
80 #[serde(default)]
81 pub kits: Vec<ContextKitPinV1>,
82 #[serde(default)]
83 pub policy_refs: Vec<PolicyId>,
84 pub integration_depth: ContextSdkIntegrationDepthV1,
85}
86
87impl ContextSessionConfigurationV1 {
88 pub fn validate(&self) -> Result<(), ValidationError> {
90 let mut kit_digests = BTreeMap::new();
91 for kit in &self.kits {
92 let logical_identity = (kit.kit_id.clone(), kit.version.clone());
93 if let Some(existing_digest) = kit_digests.insert(logical_identity, &kit.package_digest)
94 {
95 if existing_digest == &kit.package_digest {
96 return Err(ValidationError::new(
97 "ContextSessionConfigurationV1 contains a duplicate Context Kit pin",
98 ));
99 }
100 return Err(ValidationError::new(
101 "ContextSessionConfigurationV1 detects a Context Kit logical identity collision",
102 ));
103 }
104 }
105
106 let mut policy_refs = BTreeSet::new();
107 if self
108 .policy_refs
109 .iter()
110 .any(|policy_ref| !policy_refs.insert(policy_ref))
111 {
112 return Err(ValidationError::new(
113 "ContextSessionConfigurationV1 contains a duplicate policy_ref",
114 ));
115 }
116 Ok(())
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum ContextSessionPhaseV1 {
124 Created,
125 Configured,
126 Executing,
127 ReceiptReady,
128 Closed,
129 Aborted,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum ContextSessionRecoveryStateV1 {
136 Resumable,
137 ResumableWithDegradation,
138 InspectOnly,
139 Corrupt,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct ContextSessionStateV1 {
146 pub phase: ContextSessionPhaseV1,
147 pub revision: u64,
148 pub next_event_sequence: u64,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub active_plan_id: Option<PlanId>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub receipt_id: Option<ReceiptId>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub last_checkpoint_digest: Option<Sha256Digest>,
155 pub recovery_state: ContextSessionRecoveryStateV1,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub abort_reason: Option<ProtocolReference>,
158}
159
160impl ContextSessionStateV1 {
161 pub fn validate(&self) -> Result<(), ValidationError> {
163 if self.next_event_sequence < self.revision {
164 return Err(ValidationError::new(
165 "ContextSessionStateV1 next_event_sequence must not precede revision",
166 ));
167 }
168 if self.phase == ContextSessionPhaseV1::ReceiptReady && self.receipt_id.is_none() {
169 return Err(ValidationError::new(
170 "ContextSessionStateV1 receipt_ready requires a receipt_id",
171 ));
172 }
173 if !matches!(
174 self.phase,
175 ContextSessionPhaseV1::ReceiptReady
176 | ContextSessionPhaseV1::Closed
177 | ContextSessionPhaseV1::Aborted
178 ) && self.receipt_id.is_some()
179 {
180 return Err(ValidationError::new(
181 "ContextSessionStateV1 receipt_id is allowed only for receipt_ready, closed, or aborted",
182 ));
183 }
184 if matches!(
185 self.phase,
186 ContextSessionPhaseV1::Created
187 | ContextSessionPhaseV1::Configured
188 | ContextSessionPhaseV1::Aborted
189 ) && self.active_plan_id.is_some()
190 {
191 return Err(ValidationError::new(
192 "ContextSessionStateV1 active_plan_id requires an executing or terminal configured session",
193 ));
194 }
195 if self.phase == ContextSessionPhaseV1::Aborted && self.abort_reason.is_none() {
196 return Err(ValidationError::new(
197 "ContextSessionStateV1 aborted requires an abort_reason",
198 ));
199 }
200 if self.phase != ContextSessionPhaseV1::Aborted && self.abort_reason.is_some() {
201 return Err(ValidationError::new(
202 "ContextSessionStateV1 abort_reason is allowed only for aborted",
203 ));
204 }
205 if matches!(
206 self.recovery_state,
207 ContextSessionRecoveryStateV1::InspectOnly | ContextSessionRecoveryStateV1::Corrupt
208 ) && self.phase == ContextSessionPhaseV1::Executing
209 {
210 return Err(ValidationError::new(
211 "ContextSessionStateV1 inspect_only or corrupt state cannot execute",
212 ));
213 }
214 Ok(())
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct ContextSessionSnapshotV1 {
222 #[serde(deserialize_with = "deserialize_schema_version")]
223 pub schema_version: u32,
224 pub identity: SessionIdentityV1,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub configuration: Option<ContextSessionConfigurationV1>,
227 pub state: ContextSessionStateV1,
228}
229
230impl ContextSessionSnapshotV1 {
231 pub const SCHEMA_VERSION: u32 = V1_SCHEMA_VERSION;
233
234 pub fn validate(&self) -> Result<(), ValidationError> {
236 validate_schema_version(self.schema_version)?;
237 self.identity.validate()?;
238 if let Some(configuration) = &self.configuration {
239 configuration.validate()?;
240 }
241 self.state.validate()?;
242
243 let configuration_required = matches!(
244 self.state.phase,
245 ContextSessionPhaseV1::Configured
246 | ContextSessionPhaseV1::Executing
247 | ContextSessionPhaseV1::ReceiptReady
248 );
249 if configuration_required && self.configuration.is_none() {
250 return Err(ValidationError::new(
251 "ContextSessionSnapshotV1 configured, executing, and receipt_ready phases require configuration",
252 ));
253 }
254 if self.state.phase == ContextSessionPhaseV1::Created && self.configuration.is_some() {
255 return Err(ValidationError::new(
256 "ContextSessionSnapshotV1 created phase must not contain configuration",
257 ));
258 }
259 Ok(())
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use serde_json::{Value, json};
267
268 const DIGEST_A: &str =
269 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
270 const DIGEST_B: &str =
271 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
272
273 fn id<T>(value: &str) -> T
274 where
275 T: TryFrom<String>,
276 <T as TryFrom<String>>::Error: std::fmt::Debug,
277 {
278 T::try_from(value.to_owned()).expect("test identity should be valid")
279 }
280
281 fn reference(value: &str) -> ProtocolReference {
282 ProtocolReference::new(value).expect("test reference should be valid")
283 }
284
285 fn digest(value: &str) -> Sha256Digest {
286 Sha256Digest::new(value).expect("test digest should be valid")
287 }
288
289 fn identity() -> SessionIdentityV1 {
290 SessionIdentityV1 {
291 session_id: id("session-1"),
292 task_id: id("task-1"),
293 run_id: id("run-1"),
294 trace_id: id("trace-1"),
295 parent_task_id: None,
296 agent_id: id("agent-1"),
297 project_id: id("project-1"),
298 workspace_id: Some(id("workspace-1")),
299 tenant_id: Some(id("tenant-1")),
300 project_root_ref: reference("project:root-redacted"),
301 project_revision_ref: Some(reference("git:abcdef")),
302 created_at: UtcTimestamp::new("2026-08-22T10:20:30Z").expect("valid timestamp"),
303 }
304 }
305
306 fn configuration() -> ContextSessionConfigurationV1 {
307 ContextSessionConfigurationV1 {
308 tuning_profile: TuningProfilePinV1 {
309 profile_id: id("acme/payments-review"),
310 version: SemanticVersion::new("1.4.2").expect("valid semver"),
311 content_digest: digest(DIGEST_A),
312 source_ref: reference("profile:acme/payments-review"),
313 },
314 kits: vec![ContextKitPinV1 {
315 kit_id: id("payments-security"),
316 version: SemanticVersion::new("2.1.0").expect("valid semver"),
317 package_digest: digest(DIGEST_B),
318 activation_ref: reference("activation:payments-security"),
319 }],
320 policy_refs: vec![id("policy:local-only")],
321 integration_depth: ContextSdkIntegrationDepthV1::Wrap,
322 }
323 }
324
325 fn snapshot() -> ContextSessionSnapshotV1 {
326 ContextSessionSnapshotV1 {
327 schema_version: 1,
328 identity: identity(),
329 configuration: Some(configuration()),
330 state: ContextSessionStateV1 {
331 phase: ContextSessionPhaseV1::Executing,
332 revision: 3,
333 next_event_sequence: 4,
334 active_plan_id: Some(id("plan-1")),
335 receipt_id: None,
336 last_checkpoint_digest: Some(digest(DIGEST_A)),
337 recovery_state: ContextSessionRecoveryStateV1::Resumable,
338 abort_reason: None,
339 },
340 }
341 }
342
343 #[test]
344 fn golden_snapshot_round_trips_with_exact_pins() {
345 let snapshot = snapshot();
346 snapshot.validate().expect("snapshot must validate");
347 let value = serde_json::to_value(&snapshot).expect("snapshot serializes");
348 assert_eq!(
349 value,
350 json!({
351 "schema_version": 1,
352 "identity": {
353 "session_id": "session-1", "task_id": "task-1", "run_id": "run-1",
354 "trace_id": "trace-1", "agent_id": "agent-1", "project_id": "project-1",
355 "workspace_id": "workspace-1", "tenant_id": "tenant-1",
356 "project_root_ref": "project:root-redacted", "project_revision_ref": "git:abcdef",
357 "created_at": "2026-08-22T10:20:30Z"
358 },
359 "configuration": {
360 "tuning_profile": {
361 "profile_id": "acme/payments-review", "version": "1.4.2",
362 "content_digest": DIGEST_A, "source_ref": "profile:acme/payments-review"
363 },
364 "kits": [{
365 "kit_id": "payments-security", "version": "2.1.0",
366 "package_digest": DIGEST_B, "activation_ref": "activation:payments-security"
367 }],
368 "policy_refs": ["policy:local-only"],
369 "integration_depth": "wrap"
370 },
371 "state": {
372 "phase": "executing", "revision": 3, "next_event_sequence": 4,
373 "active_plan_id": "plan-1", "last_checkpoint_digest": DIGEST_A,
374 "recovery_state": "resumable"
375 }
376 })
377 );
378 let decoded: ContextSessionSnapshotV1 =
379 serde_json::from_value(value).expect("golden snapshot decodes");
380 assert_eq!(decoded, snapshot);
381 }
382
383 #[test]
384 fn strict_wire_contract_rejects_unknown_fields_and_bad_hashes() {
385 let mut value = serde_json::to_value(snapshot()).expect("snapshot serializes");
386 value["unknown"] = Value::Bool(true);
387 assert!(serde_json::from_value::<ContextSessionSnapshotV1>(value).is_err());
388
389 let mut value = serde_json::to_value(snapshot()).expect("snapshot serializes");
390 value["configuration"]["tuning_profile"]["content_digest"] =
391 Value::String("sha256:uppercase".to_owned());
392 assert!(serde_json::from_value::<ContextSessionSnapshotV1>(value).is_err());
393 }
394
395 #[test]
396 fn semantic_validation_rejects_invalid_lifecycle_and_colliding_kits() {
397 let mut invalid = snapshot();
398 invalid.configuration = None;
399 assert!(invalid.validate().is_err());
400
401 let mut invalid = snapshot();
402 invalid.state.phase = ContextSessionPhaseV1::ReceiptReady;
403 invalid.state.active_plan_id = None;
404 assert!(invalid.validate().is_err());
405
406 let mut invalid = snapshot();
407 let kit = invalid
408 .configuration
409 .as_mut()
410 .expect("configuration exists")
411 .kits[0]
412 .clone();
413 invalid
414 .configuration
415 .as_mut()
416 .expect("configuration exists")
417 .kits
418 .push(kit);
419 assert!(invalid.validate().is_err());
420
421 let mut invalid = snapshot();
422 invalid.state.recovery_state = ContextSessionRecoveryStateV1::Corrupt;
423 assert!(invalid.validate().is_err());
424 }
425
426 #[test]
427 fn created_and_aborted_states_have_explicit_safe_shape() {
428 let mut created = snapshot();
429 created.configuration = None;
430 created.state = ContextSessionStateV1 {
431 phase: ContextSessionPhaseV1::Created,
432 revision: 0,
433 next_event_sequence: 0,
434 active_plan_id: None,
435 receipt_id: None,
436 last_checkpoint_digest: None,
437 recovery_state: ContextSessionRecoveryStateV1::Resumable,
438 abort_reason: None,
439 };
440 created.validate().expect("created state is valid");
441
442 created.state.phase = ContextSessionPhaseV1::Aborted;
443 assert!(created.validate().is_err());
444 created.state.abort_reason = Some(reference("abort:setup-failure"));
445 created.validate().expect("aborted state has a reason");
446 }
447}