Skip to main content

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