Skip to main content

meerkat_core/
surface_metadata.rs

1//! Shared surface/runtime metadata contracts.
2//!
3//! Surface metadata is caller-owned annotation used for filtering and UI
4//! projection. It is never semantic authority. Meerkat-owned labels and
5//! metadata keys are reserved so public callers cannot spoof runtime facts.
6
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10/// Label prefix reserved for Meerkat-owned runtime facts.
11pub const MEERKAT_METADATA_PREFIX: &str = "meerkat.";
12
13/// Legacy mob discovery labels that are stamped by the mob runtime.
14pub const RESERVED_MOB_LABEL_KEYS: [&str; 3] = ["mob_id", "role", "meerkat_id"];
15
16/// Opaque caller-owned metadata shared across public surfaces.
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[serde(rename_all = "snake_case")]
20pub struct SurfaceMetadata {
21    /// Caller-owned labels for filtering and projection.
22    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
23    pub labels: BTreeMap<String, String>,
24    /// Caller-owned opaque application context.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub app_context: Option<serde_json::Value>,
27}
28
29impl SurfaceMetadata {
30    /// Build metadata from the existing optional create-surface fields.
31    #[must_use]
32    pub fn from_optional_parts(
33        labels: Option<BTreeMap<String, String>>,
34        app_context: Option<serde_json::Value>,
35    ) -> Self {
36        Self {
37            labels: labels.unwrap_or_default(),
38            app_context,
39        }
40    }
41
42    /// Return whether this metadata is empty.
43    #[must_use]
44    pub fn is_empty(&self) -> bool {
45        self.labels.is_empty() && self.app_context.is_none()
46    }
47
48    /// Validate caller-supplied metadata against Meerkat-owned keys.
49    pub fn validate_public(&self) -> Result<(), SurfaceMetadataError> {
50        validate_public_labels(Some(&self.labels))?;
51        validate_public_app_context(self.app_context.as_ref())
52    }
53}
54
55/// Runtime-carried metadata projection.
56///
57/// This type intentionally wraps surface metadata instead of adding semantic
58/// meaning. Runtime-owned facts belong in typed runtime state, not in labels.
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(rename_all = "snake_case")]
62pub struct RuntimeMetadata {
63    #[serde(default, skip_serializing_if = "SurfaceMetadata::is_empty")]
64    pub surface: SurfaceMetadata,
65}
66
67impl RuntimeMetadata {
68    /// Build a runtime projection from caller-owned surface metadata.
69    #[must_use]
70    pub fn from_surface(surface: SurfaceMetadata) -> Self {
71        Self { surface }
72    }
73
74    /// Return whether this runtime projection carries no caller-owned metadata.
75    #[must_use]
76    pub fn is_empty(&self) -> bool {
77        self.surface.is_empty()
78    }
79}
80
81impl From<SurfaceMetadata> for RuntimeMetadata {
82    fn from(surface: SurfaceMetadata) -> Self {
83        Self::from_surface(surface)
84    }
85}
86
87/// Metadata validation failures for public surfaces.
88#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
89pub enum SurfaceMetadataError {
90    /// A caller attempted to set a Meerkat-owned label key.
91    #[error("metadata label key '{key}' is reserved for Meerkat-owned runtime facts")]
92    ReservedLabelKey { key: String },
93    /// A caller attempted to set a Meerkat-owned top-level app-context key.
94    #[error("app_context key '{key}' is reserved for Meerkat-owned runtime facts")]
95    ReservedAppContextKey { key: String },
96}
97
98/// Check whether a metadata key is reserved for Meerkat-owned facts.
99#[must_use]
100pub fn is_reserved_meerkat_metadata_key(key: &str) -> bool {
101    let key = key.to_ascii_lowercase();
102    key == "meerkat" || key.starts_with(MEERKAT_METADATA_PREFIX)
103}
104
105/// Check whether a label key is reserved for Meerkat-owned facts.
106#[must_use]
107pub fn is_reserved_meerkat_label_key(key: &str) -> bool {
108    let normalized = key.to_ascii_lowercase();
109    RESERVED_MOB_LABEL_KEYS.contains(&normalized.as_str())
110        || is_reserved_meerkat_metadata_key(&normalized)
111}
112
113/// Validate caller-supplied labels.
114pub fn validate_public_labels(
115    labels: Option<&BTreeMap<String, String>>,
116) -> Result<(), SurfaceMetadataError> {
117    let Some(labels) = labels else {
118        return Ok(());
119    };
120
121    for key in labels.keys() {
122        if is_reserved_meerkat_label_key(key) {
123            return Err(SurfaceMetadataError::ReservedLabelKey { key: key.clone() });
124        }
125    }
126
127    Ok(())
128}
129
130/// Validate top-level caller-supplied app context keys.
131pub fn validate_public_app_context(
132    app_context: Option<&serde_json::Value>,
133) -> Result<(), SurfaceMetadataError> {
134    let Some(serde_json::Value::Object(map)) = app_context else {
135        return Ok(());
136    };
137
138    for key in map.keys() {
139        if is_reserved_meerkat_metadata_key(key) {
140            return Err(SurfaceMetadataError::ReservedAppContextKey { key: key.clone() });
141        }
142    }
143
144    Ok(())
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used)]
149mod tests {
150    use super::*;
151    use serde_json::json;
152
153    #[test]
154    fn surface_metadata_omits_empty_fields() {
155        let encoded = serde_json::to_value(SurfaceMetadata::default()).unwrap();
156        assert_eq!(encoded, json!({}));
157    }
158
159    #[test]
160    fn surface_metadata_round_trips_existing_labels_and_app_context_shape() {
161        let metadata = SurfaceMetadata::from_optional_parts(
162            Some(BTreeMap::from([(
163                "client.thread_id".into(),
164                "thread-1".into(),
165            )])),
166            Some(json!({"client_ref": {"view": "compact"}})),
167        );
168
169        let encoded = serde_json::to_value(&metadata).unwrap();
170        assert_eq!(
171            encoded,
172            json!({
173                "labels": { "client.thread_id": "thread-1" },
174                "app_context": { "client_ref": { "view": "compact" } }
175            })
176        );
177        assert_eq!(
178            serde_json::from_value::<SurfaceMetadata>(encoded).unwrap(),
179            metadata
180        );
181    }
182
183    #[test]
184    fn public_validation_rejects_meerkat_owned_label_keys() {
185        for key in [
186            "mob_id",
187            "role",
188            "meerkat_id",
189            "meerkat.runtime_id",
190            "Meerkat.Runtime_Id",
191            "ROLE",
192        ] {
193            let metadata = SurfaceMetadata::from_optional_parts(
194                Some(BTreeMap::from([(key.to_string(), "spoof".to_string())])),
195                None,
196            );
197            assert!(matches!(
198                metadata.validate_public(),
199                Err(SurfaceMetadataError::ReservedLabelKey { .. })
200            ));
201        }
202    }
203
204    #[test]
205    fn public_validation_rejects_meerkat_owned_app_context_keys() {
206        let metadata = SurfaceMetadata::from_optional_parts(
207            None,
208            Some(json!({
209                "Meerkat.Runtime_Id": "spoof",
210                "client_ref": "ok"
211            })),
212        );
213
214        assert!(matches!(
215            metadata.validate_public(),
216            Err(SurfaceMetadataError::ReservedAppContextKey { .. })
217        ));
218    }
219}