Skip to main content

awaken_runtime_contract/
config_record.rs

1//! Metadata envelope wrapping any spec stored in ConfigStore.
2//!
3//! Today most ConfigStore entries are bare specs (e.g. `AgentSpec` JSON).
4//! This envelope carries provenance (was this seeded by the binary, or written
5//! by a user?) and lifecycle flags (`hidden`) without breaking existing on-disk
6//! data. The decoder accepts both shapes; the encoder always emits the envelope.
7
8use serde::{Deserialize, Serialize};
9
10use crate::agent_spec_patch::{AgentSpecPatch, merge_agent_spec};
11use crate::registry_spec::{
12    A2aServerSpec, AgentSpec, BackendConfigError, McpServerSpec, ModelPoolSpec, ModelSpec,
13    ProviderSpec,
14};
15use crate::skill_spec::SkillSpec;
16use crate::skill_spec_patch::{SkillSpecPatch, merge_skill_spec};
17use crate::tool_spec::ToolSpec;
18use crate::tool_spec_patch::{ToolSpecPatch, merge_tool_spec};
19
20/// Wrapper carrying a spec plus provenance + lifecycle metadata.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ConfigRecord<T> {
23    pub spec: T,
24    pub meta: RecordMeta,
25}
26
27/// Provenance + lifecycle metadata for a stored spec.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct RecordMeta {
30    pub source: RecordSource,
31    #[serde(default)]
32    pub hidden: bool,
33    /// Field-level overrides for Builtin records.
34    /// Decoded by spec-type-specific helpers downstream; opaque at this layer.
35    /// `None` for User records and for Builtin records that have not been customized.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub user_overrides: Option<serde_json::Value>,
38    /// Milliseconds since UNIX epoch (see `crate::time::now_ms`).
39    /// `0` is a sentinel meaning "unknown / pre-envelope legacy entry".
40    #[serde(default)]
41    pub created_at: u64,
42    #[serde(default)]
43    pub updated_at: u64,
44    /// Monotonic revision number for optimistic concurrency control.
45    /// Bumped by `ConfigStore::put_if_revision` on each successful CAS write.
46    /// Legacy records deserialise as 0; first `put_if_revision(... expected=0)`
47    /// promotes them to 1.
48    #[serde(default)]
49    pub revision: u64,
50}
51
52/// Who wrote this record into ConfigStore.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(tag = "kind", rename_all = "snake_case")]
55pub enum RecordSource {
56    /// Written by binary startup seed; `binary_version` lets the next boot
57    /// detect upgrades and refresh non-user-touched fields.
58    Builtin { binary_version: String },
59    /// Written by a user via UI/HTTP (or a script). Never overwritten by seed.
60    User,
61}
62
63/// Empty patch for spec types that do not support field-level overrides.
64///
65/// The empty patch rejects unknown fields, so a non-empty `user_overrides`
66/// payload on a non-patchable spec fails validation instead of being silently
67/// ignored.
68#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
69#[serde(deny_unknown_fields)]
70pub struct NoConfigPatch {}
71
72/// Spec types that can apply `RecordMeta::user_overrides` at read time.
73pub trait ConfigRecordMerge: Sized {
74    type Patch: serde::de::DeserializeOwned;
75
76    fn merge_patch(self, patch: Self::Patch) -> Result<Self, ConfigRecordError>;
77}
78
79impl ConfigRecordMerge for AgentSpec {
80    type Patch = AgentSpecPatch;
81
82    fn merge_patch(self, patch: AgentSpecPatch) -> Result<Self, ConfigRecordError> {
83        merge_agent_spec(self, patch).map_err(ConfigRecordError::BackendConfig)
84    }
85}
86
87impl ConfigRecordMerge for ToolSpec {
88    type Patch = ToolSpecPatch;
89
90    fn merge_patch(self, patch: ToolSpecPatch) -> Result<Self, ConfigRecordError> {
91        Ok(merge_tool_spec(self, patch))
92    }
93}
94
95impl ConfigRecordMerge for SkillSpec {
96    type Patch = SkillSpecPatch;
97
98    fn merge_patch(self, patch: SkillSpecPatch) -> Result<Self, ConfigRecordError> {
99        Ok(merge_skill_spec(self, patch))
100    }
101}
102
103impl ConfigRecordMerge for ProviderSpec {
104    type Patch = NoConfigPatch;
105
106    fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
107        Ok(self)
108    }
109}
110
111impl ConfigRecordMerge for ModelSpec {
112    type Patch = NoConfigPatch;
113
114    fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
115        Ok(self)
116    }
117}
118
119impl ConfigRecordMerge for ModelPoolSpec {
120    type Patch = NoConfigPatch;
121
122    fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
123        Ok(self)
124    }
125}
126
127impl ConfigRecordMerge for McpServerSpec {
128    type Patch = NoConfigPatch;
129
130    fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
131        Ok(self)
132    }
133}
134
135impl ConfigRecordMerge for A2aServerSpec {
136    type Patch = NoConfigPatch;
137
138    fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
139        Ok(self)
140    }
141}
142
143/// Error returned while decoding a [`ConfigRecord`] or applying its overrides.
144#[derive(Debug, thiserror::Error)]
145pub enum ConfigRecordError {
146    #[error("invalid config record: {0}")]
147    Decode(#[source] serde_json::Error),
148    #[error("invalid config record overrides: {0}")]
149    Overrides(#[source] serde_json::Error),
150    #[error("invalid config record backend: {0}")]
151    BackendConfig(#[source] BackendConfigError),
152}
153
154impl<T: serde::de::DeserializeOwned> ConfigRecord<T> {
155    /// Decode a JSON value, accepting either the new envelope shape OR a
156    /// legacy bare-spec shape (in which case the record is synthesized as
157    /// `RecordSource::User`, `hidden = false`, timestamps = `0`).
158    ///
159    /// Detection rule: a value is the envelope if it is an object containing
160    /// both `"spec"` and `"meta"` keys.
161    pub fn from_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
162        if is_envelope(&value) {
163            serde_json::from_value(value)
164        } else {
165            let spec: T = serde_json::from_value(value)?;
166            Ok(Self {
167                spec,
168                meta: RecordMeta::legacy_user(),
169            })
170        }
171    }
172}
173
174/// Decode a value into [`ConfigRecord<T>`], accepting either an envelope or a
175/// legacy bare spec. This does not validate `RecordMeta::user_overrides`.
176pub fn decode_config_record<T>(
177    value: serde_json::Value,
178) -> Result<ConfigRecord<T>, ConfigRecordError>
179where
180    T: serde::de::DeserializeOwned,
181{
182    ConfigRecord::from_value(value).map_err(ConfigRecordError::Decode)
183}
184
185/// Decode a [`ConfigRecord<T>`] and validate its `RecordMeta::user_overrides`
186/// against the patch type for `T`.
187pub fn validate_config_record<T>(
188    value: serde_json::Value,
189) -> Result<ConfigRecord<T>, ConfigRecordError>
190where
191    T: serde::de::DeserializeOwned + ConfigRecordMerge,
192{
193    let record = decode_config_record::<T>(value)?;
194    validate_config_record_overrides::<T>(&record)?;
195    Ok(record)
196}
197
198/// Validate `RecordMeta::user_overrides` for an already decoded record.
199pub fn validate_config_record_overrides<T>(
200    record: &ConfigRecord<T>,
201) -> Result<(), ConfigRecordError>
202where
203    T: ConfigRecordMerge,
204{
205    if let Some(overrides) = &record.meta.user_overrides {
206        serde_json::from_value::<T::Patch>(overrides.clone())
207            .map_err(ConfigRecordError::Overrides)?;
208    }
209    Ok(())
210}
211
212/// Apply `RecordMeta::user_overrides` to the record's base spec.
213pub fn effective_config_record<T>(record: ConfigRecord<T>) -> Result<T, ConfigRecordError>
214where
215    T: ConfigRecordMerge,
216{
217    let Some(overrides) = record.meta.user_overrides else {
218        return Ok(record.spec);
219    };
220    let patch: T::Patch =
221        serde_json::from_value(overrides).map_err(ConfigRecordError::Overrides)?;
222    record.spec.merge_patch(patch)
223}
224
225/// Decode visible records and return their effective specs.
226///
227/// Hidden records are skipped. Legacy bare specs are accepted and treated as
228/// user-source records with no overrides.
229pub fn effective_visible_config_records<T, I>(records: I) -> Result<Vec<T>, ConfigRecordError>
230where
231    T: serde::de::DeserializeOwned + ConfigRecordMerge,
232    I: IntoIterator<Item = serde_json::Value>,
233{
234    let mut out = Vec::new();
235    for value in records {
236        let record = validate_config_record::<T>(value)?;
237        if record.meta.hidden {
238            continue;
239        }
240        out.push(effective_config_record(record)?);
241    }
242    Ok(out)
243}
244
245impl<T: Serialize> ConfigRecord<T> {
246    /// Encode as the new envelope JSON. Always emits the envelope shape.
247    pub fn to_value(&self) -> Result<serde_json::Value, serde_json::Error> {
248        serde_json::to_value(self)
249    }
250}
251
252impl RecordMeta {
253    /// Synthesize metadata for a legacy bare-spec entry. Timestamps are `0`
254    /// to mark them as unknown.
255    pub fn legacy_user() -> Self {
256        Self {
257            source: RecordSource::User,
258            hidden: false,
259            user_overrides: None,
260            created_at: 0,
261            updated_at: 0,
262            revision: 0,
263        }
264    }
265
266    /// Construct a fresh User record with current timestamps.
267    pub fn new_user() -> Self {
268        let now = crate::time::now_ms();
269        Self {
270            source: RecordSource::User,
271            hidden: false,
272            user_overrides: None,
273            created_at: now,
274            updated_at: now,
275            revision: 0,
276        }
277    }
278
279    /// Construct a fresh Builtin record with current timestamps.
280    pub fn new_builtin(binary_version: impl Into<String>) -> Self {
281        let now = crate::time::now_ms();
282        Self {
283            source: RecordSource::Builtin {
284                binary_version: binary_version.into(),
285            },
286            hidden: false,
287            user_overrides: None,
288            created_at: now,
289            updated_at: now,
290            revision: 0,
291        }
292    }
293}
294
295fn is_envelope(value: &serde_json::Value) -> bool {
296    matches!(value, serde_json::Value::Object(map) if map.contains_key("spec") && map.contains_key("meta"))
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn legacy_json_without_revision_deserialises_to_zero() {
305        // Simulate a legacy RecordMeta JSON that has no `revision` field.
306        let json = serde_json::json!({
307            "source": {"kind": "user"},
308            "hidden": false,
309            "created_at": 1000,
310            "updated_at": 2000
311        });
312        let meta: RecordMeta = serde_json::from_value(json).unwrap();
313        assert_eq!(meta.revision, 0);
314        assert_eq!(meta.created_at, 1000);
315        assert_eq!(meta.updated_at, 2000);
316    }
317
318    #[test]
319    fn round_trip_preserves_revision() {
320        let meta = RecordMeta {
321            source: RecordSource::User,
322            hidden: false,
323            user_overrides: None,
324            created_at: 100,
325            updated_at: 200,
326            revision: 7,
327        };
328        let serialized = serde_json::to_value(&meta).unwrap();
329        let deserialized: RecordMeta = serde_json::from_value(serialized).unwrap();
330        assert_eq!(deserialized.revision, 7);
331    }
332
333    #[test]
334    fn constructors_default_revision_to_zero() {
335        assert_eq!(RecordMeta::legacy_user().revision, 0);
336        assert_eq!(RecordMeta::new_user().revision, 0);
337        assert_eq!(RecordMeta::new_builtin("1.0.0").revision, 0);
338    }
339}