meerkat_core/
surface_metadata.rs1use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10pub const MEERKAT_METADATA_PREFIX: &str = "meerkat.";
12
13pub const RESERVED_MOB_LABEL_KEYS: [&str; 3] = ["mob_id", "role", "meerkat_id"];
15
16#[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 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
23 pub labels: BTreeMap<String, String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub app_context: Option<serde_json::Value>,
27}
28
29impl SurfaceMetadata {
30 #[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 #[must_use]
44 pub fn is_empty(&self) -> bool {
45 self.labels.is_empty() && self.app_context.is_none()
46 }
47
48 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#[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 #[must_use]
70 pub fn from_surface(surface: SurfaceMetadata) -> Self {
71 Self { surface }
72 }
73
74 #[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
89pub enum SurfaceMetadataError {
90 #[error("metadata label key '{key}' is reserved for Meerkat-owned runtime facts")]
92 ReservedLabelKey { key: String },
93 #[error("app_context key '{key}' is reserved for Meerkat-owned runtime facts")]
95 ReservedAppContextKey { key: String },
96}
97
98#[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#[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
113pub 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
130pub 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}