Skip to main content

vtcode_core/core/
state_schema.rs

1//! Versioned state schemas: explicit schema versioning and migration for durable state.
2//!
3//! Following the principle of "state as a first-class citizen" (Hitchhiker's Guide
4//! to Agentic AI, Section 18.6), all serialized state carries an explicit schema
5//! version so that forward/backward migration is safe and auditable.
6//!
7//! ## Schema History
8//!
9//! | Version | Description |
10//! |---------|-------------|
11//! | v0      | Implicit — no schema version field present (legacy snapshots, pre-2025) |
12//! | v1      | Initial explicit schema. Adds per-message metadata scaffolding. |
13//! | v2      | Snapshot-local turn linkage and diagnostics; unrelated state remains on v1. |
14//!
15//! ## Migration
16//!
17//! Every `VersionedState` implementor provides a `migrate()` method that walks
18//! from its current version to the target, one step at a time. A snapshot at v0
19//! is first migrated to v1, then to v2, and so on. This keeps each migration
20//! step small and testable.
21
22use anyhow::Result;
23use serde::de::DeserializeOwned;
24use serde::{Deserialize, Serialize};
25
26/// Explicit schema version for durable agent state.
27///
28/// Wraps a `u32` so the version is a distinct type, not a bare integer that
29/// could be confused with other version fields.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31pub struct SchemaVersion(pub u32);
32
33impl SchemaVersion {
34    /// Implicit pre-schema state: no version field present in serialized data.
35    pub const V0: SchemaVersion = SchemaVersion(0);
36
37    /// Initial explicit schema. Adds per-message metadata placeholders.
38    pub const V1: SchemaVersion = SchemaVersion(1);
39
40    /// The current schema version that all new state should use.
41    pub const CURRENT: SchemaVersion = SchemaVersion::V1;
42
43    /// Snapshot turn metadata/diagnostics.
44    pub const V2: SchemaVersion = SchemaVersion(2);
45
46    /// Return the inner `u32` value.
47    pub const fn as_u32(self) -> u32 {
48        self.0
49    }
50}
51
52impl Default for SchemaVersion {
53    fn default() -> Self {
54        Self::CURRENT
55    }
56}
57
58/// Trait for types whose serialized representation carries a schema version
59/// and can be migrated forward to a newer version.
60///
61/// Implementors provide `migrate_one_step`, and the default `migrate()` method
62/// walks through all intermediate versions automatically.
63pub trait VersionedState: Sized + Serialize + DeserializeOwned {
64    /// Return the schema version this instance was created at.
65    fn schema_version(&self) -> SchemaVersion;
66
67    /// The next schema version after `current`, if any.
68    fn next_version(current: SchemaVersion) -> Option<SchemaVersion> {
69        match current {
70            SchemaVersion::V0 => Some(SchemaVersion::V1),
71            SchemaVersion::V1 => None,
72            _ => None,
73        }
74    }
75
76    /// Migrate this instance to `target`, stepping through intermediate
77    /// versions one at a time. Returns the fully migrated instance or an
78    /// error if a migration step fails.
79    ///
80    /// If `target <= self.schema_version()` this is a no-op.
81    fn migrate(self, target: SchemaVersion) -> Result<Self> {
82        let mut current = self.schema_version();
83        if current >= target {
84            return Ok(self);
85        }
86        let mut value = self;
87        loop {
88            let next = Self::next_version(current);
89            match next {
90                Some(version) if version <= target => {
91                    value = value.migrate_one_step(current, version)?;
92                    current = version;
93                }
94                _ => break,
95            }
96        }
97        Ok(value)
98    }
99
100    /// Perform a single version-to-version migration.
101    ///
102    /// Implementors should match on `(from, to)` rather than branching on
103    /// `from` alone, ensuring every transition is explicitly handled.
104    fn migrate_one_step(self, from: SchemaVersion, to: SchemaVersion) -> Result<Self>;
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    /// Verify that SchemaVersion constants have the expected values.
112    #[test]
113    fn schema_version_constants() {
114        assert_eq!(SchemaVersion::V0.as_u32(), 0);
115        assert_eq!(SchemaVersion::V1.as_u32(), 1);
116        assert_eq!(SchemaVersion::V2.as_u32(), 2);
117        assert_eq!(SchemaVersion::CURRENT.as_u32(), 1);
118    }
119
120    /// Verify the next_version chain via the TestState trait implementation.
121    #[test]
122    fn next_version_chain() {
123        assert_eq!(TestState::next_version(SchemaVersion::V0), Some(SchemaVersion::V1));
124        assert_eq!(TestState::next_version(SchemaVersion::V1), None);
125    }
126
127    /// A minimal test struct to verify the VersionedState trait compiles and
128    /// the migrate method runs correct steps.
129    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130    struct TestState {
131        version: SchemaVersion,
132        value: u32,
133    }
134
135    impl VersionedState for TestState {
136        fn schema_version(&self) -> SchemaVersion {
137            self.version
138        }
139
140        fn migrate_one_step(self, from: SchemaVersion, to: SchemaVersion) -> Result<Self> {
141            match (from, to) {
142                (SchemaVersion::V0, SchemaVersion::V1) => {
143                    // v0 -> v1: set the explicit version, default new fields
144                    Ok(Self { version: SchemaVersion::V1, ..self })
145                }
146                _ => anyhow::bail!("unsupported migration: {from:?} -> {to:?}"),
147            }
148        }
149    }
150
151    #[test]
152    fn test_migrate_v0_to_v1() {
153        let state = TestState { version: SchemaVersion::V0, value: 42 };
154        let migrated = state.migrate(SchemaVersion::V1).unwrap();
155        assert_eq!(migrated.version, SchemaVersion::V1);
156        assert_eq!(migrated.value, 42);
157    }
158
159    #[test]
160    fn test_migrate_at_target_is_noop() {
161        let state = TestState { version: SchemaVersion::V1, value: 99 };
162        let migrated = state.migrate(SchemaVersion::V1).unwrap();
163        assert_eq!(migrated.version, SchemaVersion::V1);
164        assert_eq!(migrated.value, 99);
165    }
166
167    #[test]
168    fn test_migrate_above_current_is_noop() {
169        let state = TestState { version: SchemaVersion::V0, value: 7 };
170        // Migrate to V0 (which is <= V0) — should be a no-op
171        let migrated = state.migrate(SchemaVersion::V0).unwrap();
172        assert_eq!(migrated.version, SchemaVersion::V0);
173        assert_eq!(migrated.value, 7);
174    }
175}