Skip to main content

lean_ctx_protocol/
context_session.rs

1//! Typed, immutable Context SDK session metadata and checkpoint state.
2
3use 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/// Immutable identity established when a Context SDK session is created.
13#[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    /// Validate the identity invariants shared by direct Rust construction and decoding.
36    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/// Exact immutable identity of a TuningProfile resolved for a session.
47#[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/// Exact immutable identity of a Context Kit resolved for a session.
57#[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/// Integration coverage claimed by a session, never an agent-hosting mode.
67#[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/// Frozen configuration inputs that may affect Context SDK behavior or evidence.
76#[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    /// Validate uniqueness and collision invariants for immutable resolved pins.
89    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/// Lifecycle phase for a persisted Context SDK session snapshot.
121#[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/// Result of checking whether a persisted session can safely resume execution.
133#[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/// Mutable checkpoint projection; it never replaces the append-only event log.
143#[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    /// Validate lifecycle fields without relying on a mutable runtime implementation.
162    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/// Portable V1 metadata/checkpoint contract for one Context SDK root task.
219#[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    /// Schema version represented by this Context SDK session snapshot.
232    pub const SCHEMA_VERSION: u32 = V1_SCHEMA_VERSION;
233
234    /// Validate schema, pin, and lifecycle invariants for a portable snapshot.
235    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}