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}