use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const MEERKAT_METADATA_PREFIX: &str = "meerkat.";
const MEERKAT_NAMESPACE_TOKEN: &str = "meerkat";
pub const RESERVED_MOB_LABEL_KEYS: [&str; 3] = ["mob_id", "role", "meerkat_id"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReservedMetadataKey {
MeerkatNamespace,
MobDiscoveryLabel,
SessionAuthority,
}
impl ReservedMetadataKey {
#[must_use]
pub fn classify(key: &str) -> Option<Self> {
if Self::is_session_authority(key) {
return Some(Self::SessionAuthority);
}
let normalized = key.to_ascii_lowercase();
if normalized == MEERKAT_NAMESPACE_TOKEN || normalized.starts_with(MEERKAT_METADATA_PREFIX)
{
return Some(Self::MeerkatNamespace);
}
if RESERVED_MOB_LABEL_KEYS.contains(&normalized.as_str()) {
return Some(Self::MobDiscoveryLabel);
}
None
}
#[must_use]
pub fn is_session_authority(key: &str) -> bool {
matches!(
key,
crate::session::SESSION_METADATA_KEY
| crate::session::SESSION_BUILD_STATE_KEY
| crate::session::SESSION_SYSTEM_CONTEXT_STATE_KEY
| crate::session::SESSION_DEFERRED_TURN_STATE_KEY
| crate::session::SESSION_TOOL_VISIBILITY_STATE_KEY
| crate::session::SESSION_LIFECYCLE_TERMINAL_KEY
| crate::session::SESSION_TRANSCRIPT_HISTORY_STATE_KEY
| crate::SESSION_REALTIME_TRANSCRIPT_STATE_KEY
)
}
#[must_use]
pub fn is_meerkat_namespace(self) -> bool {
matches!(self, Self::MeerkatNamespace)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub struct SurfaceMetadata {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_context: Option<serde_json::Value>,
}
impl SurfaceMetadata {
#[must_use]
pub fn from_optional_parts(
labels: Option<BTreeMap<String, String>>,
app_context: Option<serde_json::Value>,
) -> Self {
Self {
labels: labels.unwrap_or_default(),
app_context,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.labels.is_empty() && self.app_context.is_none()
}
pub fn validate_public(&self) -> Result<(), SurfaceMetadataError> {
validate_public_labels(Some(&self.labels))?;
validate_public_app_context(self.app_context.as_ref())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub struct RuntimeMetadata {
#[serde(default, skip_serializing_if = "SurfaceMetadata::is_empty")]
pub surface: SurfaceMetadata,
}
impl RuntimeMetadata {
#[must_use]
pub fn from_surface(surface: SurfaceMetadata) -> Self {
Self { surface }
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.surface.is_empty()
}
}
impl From<SurfaceMetadata> for RuntimeMetadata {
fn from(surface: SurfaceMetadata) -> Self {
Self::from_surface(surface)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SurfaceMetadataError {
#[error("metadata label key '{key}' is reserved for Meerkat-owned runtime facts")]
ReservedLabelKey { key: String },
#[error("app_context key '{key}' is reserved for Meerkat-owned runtime facts")]
ReservedAppContextKey { key: String },
}
#[must_use]
pub fn is_reserved_meerkat_metadata_key(key: &str) -> bool {
ReservedMetadataKey::classify(key).is_some_and(ReservedMetadataKey::is_meerkat_namespace)
}
#[must_use]
pub fn is_reserved_meerkat_label_key(key: &str) -> bool {
matches!(
ReservedMetadataKey::classify(key),
Some(ReservedMetadataKey::MeerkatNamespace | ReservedMetadataKey::MobDiscoveryLabel)
)
}
pub fn validate_public_labels(
labels: Option<&BTreeMap<String, String>>,
) -> Result<(), SurfaceMetadataError> {
let Some(labels) = labels else {
return Ok(());
};
for key in labels.keys() {
if is_reserved_meerkat_label_key(key) {
return Err(SurfaceMetadataError::ReservedLabelKey { key: key.clone() });
}
}
Ok(())
}
pub fn validate_public_app_context(
app_context: Option<&serde_json::Value>,
) -> Result<(), SurfaceMetadataError> {
let Some(serde_json::Value::Object(map)) = app_context else {
return Ok(());
};
for key in map.keys() {
if is_reserved_meerkat_metadata_key(key) {
return Err(SurfaceMetadataError::ReservedAppContextKey { key: key.clone() });
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn surface_metadata_omits_empty_fields() {
let encoded = serde_json::to_value(SurfaceMetadata::default()).unwrap();
assert_eq!(encoded, json!({}));
}
#[test]
fn surface_metadata_round_trips_existing_labels_and_app_context_shape() {
let metadata = SurfaceMetadata::from_optional_parts(
Some(BTreeMap::from([(
"client.thread_id".into(),
"thread-1".into(),
)])),
Some(json!({"client_ref": {"view": "compact"}})),
);
let encoded = serde_json::to_value(&metadata).unwrap();
assert_eq!(
encoded,
json!({
"labels": { "client.thread_id": "thread-1" },
"app_context": { "client_ref": { "view": "compact" } }
})
);
assert_eq!(
serde_json::from_value::<SurfaceMetadata>(encoded).unwrap(),
metadata
);
}
#[test]
fn public_validation_rejects_meerkat_owned_label_keys() {
for key in [
"mob_id",
"role",
"meerkat_id",
"meerkat.runtime_id",
"Meerkat.Runtime_Id",
"ROLE",
] {
let metadata = SurfaceMetadata::from_optional_parts(
Some(BTreeMap::from([(key.to_string(), "spoof".to_string())])),
None,
);
assert!(matches!(
metadata.validate_public(),
Err(SurfaceMetadataError::ReservedLabelKey { .. })
));
}
}
#[test]
fn public_validation_rejects_meerkat_owned_app_context_keys() {
let metadata = SurfaceMetadata::from_optional_parts(
None,
Some(json!({
"Meerkat.Runtime_Id": "spoof",
"client_ref": "ok"
})),
);
assert!(matches!(
metadata.validate_public(),
Err(SurfaceMetadataError::ReservedAppContextKey { .. })
));
}
#[test]
fn reserved_metadata_key_classifies_each_reserved_space() {
assert_eq!(
ReservedMetadataKey::classify("meerkat"),
Some(ReservedMetadataKey::MeerkatNamespace)
);
assert_eq!(
ReservedMetadataKey::classify("Meerkat.Runtime_Id"),
Some(ReservedMetadataKey::MeerkatNamespace)
);
assert_eq!(
ReservedMetadataKey::classify("mob_id"),
Some(ReservedMetadataKey::MobDiscoveryLabel)
);
assert_eq!(
ReservedMetadataKey::classify(crate::session::SESSION_BUILD_STATE_KEY),
Some(ReservedMetadataKey::SessionAuthority)
);
assert_eq!(
ReservedMetadataKey::classify(crate::SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
Some(ReservedMetadataKey::SessionAuthority)
);
assert_eq!(ReservedMetadataKey::classify("client.thread_id"), None);
}
#[test]
fn reserved_key_helpers_delegate_to_classifier() {
assert!(is_reserved_meerkat_metadata_key("meerkat.runtime_id"));
assert!(!is_reserved_meerkat_metadata_key("mob_id"));
assert!(is_reserved_meerkat_label_key("mob_id"));
assert!(is_reserved_meerkat_label_key("meerkat.foo"));
assert!(!is_reserved_meerkat_label_key("client.thread_id"));
}
}