agentmux_srv\registry/
schema.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Registry file format + per-row validation.
5//!
6//! Bumping `MAX_SUPPORTED_SCHEMA` is the additive-evolution path:
7//! readers of the previous bound still skip-and-log new files; old
8//! disk files keep validating because the v1 reader stays intact.
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Lowest envelope schema this binary will load. Bumped only with a
14/// deprecation cycle (see SPEC §6).
15pub const MIN_SUPPORTED_SCHEMA: u32 = 1;
16/// Highest envelope schema this binary will write or read. Bumped
17/// per release that adds fields.
18pub const MAX_SUPPORTED_SCHEMA: u32 = 3;
19
20/// On-disk envelope. The `data` field's shape is gated by
21/// `schema_version`; readers should match on the version before
22/// projecting.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct NamedAgentRecord {
25    pub schema_version: u32,
26    pub data: NamedAgentRecordV1,
27}
28
29/// v1 payload. Add new optional fields here under `#[serde(default)]`
30/// and bump `MAX_SUPPORTED_SCHEMA` — old readers will skip the new
31/// version, new readers fill defaults for old files.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct NamedAgentRecordV1 {
34    pub instance_id: String,
35    pub instance_name: String,
36    pub definition_id: String,
37    /// FK to `db_identity_bundles.id`. None = unbound (= ambient creds).
38    pub identity_id: Option<String>,
39    /// FK to `db_memory_bundles.id`. None = unbound (= vanilla CLI).
40    pub memory_id: Option<String>,
41    /// Provider CLI session id for `--resume` (e.g. a Claude Code session
42    /// uuid). `None` = no session wired yet (fresh stub / legacy record).
43    /// Added in schema v2 so a global/migrated record can resume the
44    /// agent's conversation across channels without a current-channel
45    /// SQLite join. Old (v1) files deserialize this as `None`.
46    #[serde(default)]
47    pub session_id: Option<String>,
48    /// Path **relative to [`source_agents_base`]** (or, for legacy records
49    /// without one, the reader's current channel agents dir) — never
50    /// absolute. Keeps the record portable across machines where the home
51    /// dir differs.
52    pub working_dir: String,
53    /// Absolute path of the agents dir that [`working_dir`] is relative to —
54    /// i.e. the channel/dev instance the agent actually lives in
55    /// (`channels/<ch>/agents` or `<dev-instance>/agents`). Added in schema
56    /// v3 (cross-channel persistence P0.4): the registry is global, so a row
57    /// surfaced in a DIFFERENT channel must reconstruct its absolute
58    /// `working_directory` against the SOURCE base, not the reader's current
59    /// channel. `None` for legacy (v1/v2) records — the reader then falls
60    /// back to its current channel agents dir, matching pre-P0.4 behavior.
61    /// Old binaries deserialize this as `None`.
62    #[serde(default)]
63    pub source_agents_base: Option<String>,
64    pub created_at_ms: i64,
65    pub last_launched_at_ms: i64,
66    pub created_by_version: String,
67    pub last_launched_by_version: String,
68}
69
70impl NamedAgentRecordV1 {
71    /// Lowest envelope schema that can faithfully represent this payload.
72    /// Climbs only when a higher-version-only field is populated, so an
73    /// older binary keeps reading records that don't use any newer feature —
74    /// only records that actually need the newer schema are hidden from it.
75    /// Writers should stamp the record with this rather than always using
76    /// `MAX_SUPPORTED_SCHEMA`.
77    ///
78    /// - v3 when `source_agents_base` is set (cross-channel reconstruction)
79    /// - v2 when `session_id` is set (cross-channel resume)
80    /// - v1 otherwise
81    pub fn min_schema_version(&self) -> u32 {
82        if self.source_agents_base.is_some() {
83            3
84        } else if self.session_id.is_some() {
85            2
86        } else {
87            1
88        }
89    }
90}
91
92#[derive(Debug, Error)]
93pub enum ValidationError {
94    #[error("schema_version {version} outside supported [{min}, {max}]")]
95    UnsupportedSchema {
96        version: u32,
97        min: u32,
98        max: u32,
99    },
100    #[error("filename UUID {filename:?} does not match data.instance_id {instance_id:?}")]
101    IdMismatch {
102        filename: String,
103        instance_id: String,
104    },
105    #[error("working_dir {0:?} is not a safe relative subpath of agents/")]
106    UnsafeWorkingDir(String),
107    #[error("required field missing: {0}")]
108    MissingField(&'static str),
109}
110
111/// Per-row validation. Fails fast on anything that would let a
112/// malformed file be returned to the launch modal. Validation
113/// failures are skipped (not auto-fixed), logged, and the file stays
114/// on disk for ops triage.
115pub fn validate(filename_stem: &str, rec: &NamedAgentRecord) -> Result<(), ValidationError> {
116    if rec.schema_version < MIN_SUPPORTED_SCHEMA || rec.schema_version > MAX_SUPPORTED_SCHEMA {
117        return Err(ValidationError::UnsupportedSchema {
118            version: rec.schema_version,
119            min: MIN_SUPPORTED_SCHEMA,
120            max: MAX_SUPPORTED_SCHEMA,
121        });
122    }
123    let d = &rec.data;
124    if d.instance_id.is_empty() {
125        return Err(ValidationError::MissingField("instance_id"));
126    }
127    if d.instance_id != filename_stem {
128        return Err(ValidationError::IdMismatch {
129            filename: filename_stem.to_string(),
130            instance_id: d.instance_id.clone(),
131        });
132    }
133    if d.instance_name.is_empty() {
134        return Err(ValidationError::MissingField("instance_name"));
135    }
136    if d.definition_id.is_empty() {
137        return Err(ValidationError::MissingField("definition_id"));
138    }
139    if d.working_dir.is_empty() {
140        return Err(ValidationError::MissingField("working_dir"));
141    }
142    if !is_safe_relative_subpath(&d.working_dir) {
143        return Err(ValidationError::UnsafeWorkingDir(d.working_dir.clone()));
144    }
145    Ok(())
146}
147
148fn is_safe_relative_subpath(s: &str) -> bool {
149    let p = std::path::Path::new(s);
150    if p.is_absolute() {
151        return false;
152    }
153    for comp in p.components() {
154        match comp {
155            std::path::Component::ParentDir
156            | std::path::Component::RootDir
157            | std::path::Component::Prefix(_) => return false,
158            _ => {}
159        }
160    }
161    true
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn v1_record(id: &str) -> NamedAgentRecord {
169        NamedAgentRecord {
170            schema_version: 1,
171            data: NamedAgentRecordV1 {
172                instance_id: id.to_string(),
173                instance_name: "demo".to_string(),
174                definition_id: "claude-code".to_string(),
175                identity_id: None,
176                memory_id: None,
177                session_id: None,
178                working_dir: "demo-0512a".to_string(),
179                source_agents_base: None,
180                created_at_ms: 1,
181                last_launched_at_ms: 1,
182                created_by_version: "0.33.822".to_string(),
183                last_launched_by_version: "0.33.822".to_string(),
184            },
185        }
186    }
187
188    #[test]
189    fn happy_path() {
190        let r = v1_record("abc");
191        validate("abc", &r).unwrap();
192    }
193
194    #[test]
195    fn unsupported_schema_is_rejected() {
196        let mut r = v1_record("abc");
197        r.schema_version = 999;
198        let err = validate("abc", &r).unwrap_err();
199        assert!(matches!(err, ValidationError::UnsupportedSchema { .. }));
200    }
201
202    #[test]
203    fn filename_mismatch_is_rejected() {
204        let r = v1_record("abc");
205        assert!(matches!(
206            validate("xyz", &r).unwrap_err(),
207            ValidationError::IdMismatch { .. }
208        ));
209    }
210
211    #[test]
212    fn absolute_workdir_is_rejected() {
213        let mut r = v1_record("abc");
214        r.data.working_dir = if cfg!(windows) {
215            "C:\\tmp\\evil".to_string()
216        } else {
217            "/tmp/evil".to_string()
218        };
219        assert!(matches!(
220            validate("abc", &r).unwrap_err(),
221            ValidationError::UnsafeWorkingDir(_)
222        ));
223    }
224
225    #[test]
226    fn dotdot_workdir_is_rejected() {
227        let mut r = v1_record("abc");
228        r.data.working_dir = "..\\..\\sneaky".to_string();
229        assert!(matches!(
230            validate("abc", &r).unwrap_err(),
231            ValidationError::UnsafeWorkingDir(_)
232        ));
233    }
234
235    #[test]
236    fn missing_required_field_is_rejected() {
237        let mut r = v1_record("abc");
238        r.data.instance_name = String::new();
239        assert!(matches!(
240            validate("abc", &r).unwrap_err(),
241            ValidationError::MissingField("instance_name")
242        ));
243    }
244
245    #[test]
246    fn session_id_drives_min_schema_version() {
247        let mut r = v1_record("abc");
248        assert_eq!(r.data.min_schema_version(), 1, "session-less record is v1");
249        r.data.session_id = Some("sess-xyz".to_string());
250        assert_eq!(r.data.min_schema_version(), 2, "session-wired record is v2");
251        // A v2 record validates under the bumped MAX_SUPPORTED_SCHEMA.
252        r.schema_version = 2;
253        validate("abc", &r).unwrap();
254    }
255
256    #[test]
257    fn source_agents_base_drives_min_schema_version_v3() {
258        let mut r = v1_record("abc");
259        assert_eq!(r.data.min_schema_version(), 1);
260        r.data.source_agents_base = Some("/home/u/.agentmux/channels/stable/agents".to_string());
261        assert_eq!(
262            r.data.min_schema_version(),
263            3,
264            "a source-anchored record needs v3"
265        );
266        // v3 takes precedence even with session_id also set.
267        r.data.session_id = Some("sess-1".to_string());
268        assert_eq!(r.data.min_schema_version(), 3);
269        // Validates under the bumped MAX_SUPPORTED_SCHEMA.
270        r.schema_version = 3;
271        validate("abc", &r).unwrap();
272    }
273
274    #[test]
275    fn unknown_future_field_round_trips() {
276        // A v3 record written by a newer binary with an unknown field must
277        // still deserialize (serde ignores unknowns) so this reader can load
278        // it — the additive-evolution contract.
279        let raw = serde_json::json!({
280            "schema_version": 3,
281            "data": {
282                "instance_id": "abc", "instance_name": "demo",
283                "definition_id": "claude-code", "identity_id": null,
284                "memory_id": null, "working_dir": "demo-0",
285                "source_agents_base": "/h/.agentmux/channels/x/agents",
286                "created_at_ms": 1, "last_launched_at_ms": 1,
287                "created_by_version": "0.45.0", "last_launched_by_version": "0.45.0",
288                "future_field": "ignored"
289            }
290        });
291        let parsed: NamedAgentRecord = serde_json::from_value(raw).unwrap();
292        assert_eq!(parsed.data.instance_id, "abc");
293        assert_eq!(
294            parsed.data.source_agents_base.as_deref(),
295            Some("/h/.agentmux/channels/x/agents")
296        );
297    }
298}