use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
pub use crate::generated::contract_catalog::{SELECTED_HEADER, SUPPORT_HEADER};
pub const POLICY_BUNDLE_FAMILY: &str = "policy_bundle";
pub const DECISION_EVENT_FAMILY: &str = "decision_event";
pub const COMPATIBILITY_PROBLEM_TYPE: &str =
"https://openlatch.ai/problems/protocol-compatibility-unavailable";
pub const COMPATIBILITY_FILE: &str = "compatibility.json";
static COMPATIBILITY_WRITE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct VersionRange {
pub oldest: u32,
pub newest: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
pub struct CompatibilityState {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub selections: BTreeMap<String, CompatibilitySelection>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub diagnostics: BTreeMap<String, CompatibilityDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CompatibilitySelection {
pub version: u32,
pub selected_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CompatibilityDiagnostic {
pub family: String,
pub client_range: VersionRange,
pub platform_range: Option<VersionRange>,
pub last_selection: Option<u32>,
pub observed_at: String,
pub detail: String,
}
#[derive(Debug, thiserror::Error)]
pub enum CompatibilityStoreError {
#[error("compatibility sidecar I/O error at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("compatibility sidecar is malformed: {0}")]
Malformed(String),
}
pub fn compatibility_path(base: &Path) -> PathBuf {
base.join("policy").join(COMPATIBILITY_FILE)
}
pub fn read_compatibility(base: &Path) -> Result<CompatibilityState, CompatibilityStoreError> {
let path = compatibility_path(base);
match std::fs::read(&path) {
Ok(raw) => serde_json::from_slice(&raw)
.map_err(|error| CompatibilityStoreError::Malformed(error.to_string())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(CompatibilityState::default())
}
Err(source) => Err(CompatibilityStoreError::Io { path, source }),
}
}
pub fn record_selection(
base: &Path,
family: &str,
version: u32,
) -> Result<(), CompatibilityStoreError> {
update_compatibility(base, |state| {
state.selections.insert(
family.to_string(),
CompatibilitySelection {
version,
selected_at: now_rfc3339(),
},
);
state.diagnostics.remove(family);
})
}
pub fn record_diagnostic(
base: &Path,
mut diagnostic: CompatibilityDiagnostic,
) -> Result<(), CompatibilityStoreError> {
update_compatibility(base, |state| {
diagnostic.last_selection = state.selections.get(&diagnostic.family).map(|s| s.version);
state
.diagnostics
.insert(diagnostic.family.clone(), diagnostic);
})
}
fn update_compatibility(
base: &Path,
update: impl FnOnce(&mut CompatibilityState),
) -> Result<(), CompatibilityStoreError> {
let _guard = COMPATIBILITY_WRITE_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut state = read_compatibility(base)?;
update(&mut state);
let path = compatibility_path(base);
let dir = path.parent().expect("compatibility sidecar has parent");
std::fs::create_dir_all(dir).map_err(|source| CompatibilityStoreError::Io {
path: dir.to_path_buf(),
source,
})?;
let bytes = serde_json::to_vec_pretty(&state)
.map_err(|error| CompatibilityStoreError::Malformed(error.to_string()))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes).map_err(|source| CompatibilityStoreError::Io {
path: tmp.clone(),
source,
})?;
std::fs::rename(&tmp, &path).map_err(|source| CompatibilityStoreError::Io { path, source })
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
impl VersionRange {
pub const fn new(oldest: u32, newest: u32) -> Option<Self> {
if oldest == 0 || newest == 0 || oldest > newest {
None
} else {
Some(Self { oldest, newest })
}
}
pub fn select(self, peer: Self) -> Option<u32> {
let oldest = self.oldest.max(peer.oldest);
let newest = self.newest.min(peer.newest);
(oldest <= newest).then_some(newest)
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum MapError {
#[error("contract map is empty")]
Empty,
#[error("malformed contract member: {0}")]
Malformed(String),
#[error("duplicate contract family: {0}")]
Duplicate(String),
#[error("contract versions must be positive and ranges must not be reversed")]
InvalidRange,
}
pub fn supported_ranges() -> BTreeMap<String, VersionRange> {
let mut result: BTreeMap<String, VersionRange> = BTreeMap::new();
for entry in crate::generated::contract_catalog::CONTRACT_VERSIONS {
result
.entry(entry.family.to_string())
.and_modify(|range| {
range.oldest = range.oldest.min(entry.version);
range.newest = range.newest.max(entry.version);
})
.or_insert(VersionRange {
oldest: entry.version,
newest: entry.version,
});
}
result
}
pub fn support_header_value() -> String {
supported_ranges()
.into_iter()
.map(|(family, range)| format!("{family}={}-{}", range.oldest, range.newest))
.collect::<Vec<_>>()
.join(",")
}
pub fn baseline(family: &str) -> Option<u32> {
crate::generated::contract_catalog::CONTRACT_VERSIONS
.iter()
.find(|entry| entry.family == family && entry.baseline)
.map(|entry| entry.version)
}
pub fn has_reader(family: &str, version: u32) -> bool {
crate::generated::contract_catalog::CONTRACT_VERSIONS
.iter()
.any(|entry| entry.family == family && entry.version == version && !entry.reader.is_empty())
}
pub fn has_writer(family: &str, version: u32) -> bool {
crate::generated::contract_catalog::CONTRACT_VERSIONS
.iter()
.any(|entry| entry.family == family && entry.version == version && !entry.writer.is_empty())
}
pub fn parse_ranges(raw: &str) -> Result<BTreeMap<String, VersionRange>, MapError> {
parse_map(raw, |value| {
let (oldest, newest) = value
.split_once('-')
.ok_or_else(|| MapError::Malformed(value.to_string()))?;
let oldest = parse_version(oldest)?;
let newest = parse_version(newest)?;
VersionRange::new(oldest, newest).ok_or(MapError::InvalidRange)
})
}
pub fn parse_selected(raw: &str) -> Result<BTreeMap<String, u32>, MapError> {
parse_map(raw, parse_version)
}
fn parse_map<T>(
raw: &str,
parse_value: impl Fn(&str) -> Result<T, MapError>,
) -> Result<BTreeMap<String, T>, MapError> {
if raw.trim().is_empty() {
return Err(MapError::Empty);
}
let mut result = BTreeMap::new();
for member in raw.split(',') {
let member = member.trim();
let (family, value) = member
.split_once('=')
.ok_or_else(|| MapError::Malformed(member.to_string()))?;
if !valid_family(family) || value.is_empty() || value.contains('=') {
return Err(MapError::Malformed(member.to_string()));
}
if result.contains_key(family) {
return Err(MapError::Duplicate(family.to_string()));
}
result.insert(family.to_string(), parse_value(value)?);
}
Ok(result)
}
fn parse_version(raw: &str) -> Result<u32, MapError> {
let version = raw.parse::<u32>().map_err(|_| MapError::InvalidRange)?;
(version > 0)
.then_some(version)
.ok_or(MapError::InvalidRange)
}
fn valid_family(raw: &str) -> bool {
let mut chars = raw.chars();
chars.next().is_some_and(|c| c.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub fn decision_event_can_write(envelope: &serde_json::Value, version: u32) -> bool {
if version >= 2 {
return true;
}
let Some(object) = envelope.as_object() else {
return false;
};
const V2_ONLY: &[&str] = &[
"olatomid",
"olpolicyid",
"oldimension",
"ollayer",
"olmode",
"olenforced",
"olresult",
"oltier",
"olspechash",
"olbinding",
"oleffects",
"olinconclusive",
"olrewrite",
"olreinforce",
"ollever",
"olhostprompted",
"olundecided",
"olverdictshadow",
];
!V2_ONLY.iter().any(|key| object.contains_key(*key))
&& object
.get("olverdict")
.and_then(serde_json::Value::as_str)
.is_none_or(|verdict| matches!(verdict, "allow" | "deny"))
}
pub fn policy_bundle_can_read(document: &serde_json::Value, version: u32) -> bool {
if version >= 2 {
return true;
}
const V2_ONLY: &[&str] = &[
"artifacts",
"facts",
"effect_classes",
"directive_templates",
"install_id",
"client_floor",
"meta",
];
V2_ONLY.iter().all(|key| {
document
.get(*key)
.is_none_or(|value| !contains_contract_facts(value))
})
}
fn contains_contract_facts(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => false,
serde_json::Value::Array(values) => !values.is_empty(),
serde_json::Value::Object(values) => !values.is_empty(),
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn published_catalogue_deserializes_to_the_schema_generated_type() {
let catalog: crate::generated::types::ContractCatalog =
serde_json::from_str(include_str!("../../../schemas/contracts/catalog.json"))
.expect("catalogue matches contract-catalog.schema.json");
assert_eq!(catalog.families.len(), 2);
assert!(catalog
.families
.iter()
.all(|family| family.versions.len() == 2));
}
#[test]
fn retained_fixtures_are_readable_by_their_local_readers() {
for raw in [
include_str!("../../../schemas/contracts/policy_bundle/v1.json"),
include_str!("../../../schemas/contracts/policy_bundle/v2.json"),
] {
let value = serde_json::from_str(raw).expect("policy fixture is JSON");
crate::core::policy::project_document(value)
.expect("retained policy fixture has a live local reader");
}
for raw in [
include_str!("../../../schemas/contracts/decision_event/v1.json"),
include_str!("../../../schemas/contracts/decision_event/v2.json"),
] {
serde_json::from_str::<crate::generated::types::EventEnvelope>(raw)
.expect("retained decision-event fixture has a live local reader");
}
}
#[test]
fn parses_ranges_and_selects_greatest_intersection() {
let parsed = parse_ranges("policy_bundle=1-2, decision_event=2-4").unwrap();
assert_eq!(
parsed[POLICY_BUNDLE_FAMILY],
VersionRange {
oldest: 1,
newest: 2
}
);
assert_eq!(
VersionRange::new(2, 6)
.unwrap()
.select(VersionRange::new(1, 5).unwrap()),
Some(5)
);
}
#[test]
fn exact_retention_boundary_is_n_minus_four_not_n_minus_five() {
let retained = VersionRange::new(2, 6).unwrap();
assert_eq!(retained.select(VersionRange::new(2, 2).unwrap()), Some(2));
assert_eq!(retained.select(VersionRange::new(1, 1).unwrap()), None);
}
#[test]
fn rejects_malformed_duplicate_zero_and_reversed_ranges() {
assert!(matches!(parse_ranges(""), Err(MapError::Empty)));
assert!(matches!(
parse_ranges("policy_bundle"),
Err(MapError::Malformed(_))
));
assert!(matches!(
parse_ranges("policy_bundle=0-2"),
Err(MapError::InvalidRange)
));
assert!(matches!(
parse_ranges("policy_bundle=2-1"),
Err(MapError::InvalidRange)
));
assert!(matches!(
parse_ranges("policy_bundle=1-2,policy_bundle=1-2"),
Err(MapError::Duplicate(_))
));
assert!(matches!(
parse_selected("policy_bundle=0"),
Err(MapError::InvalidRange)
));
}
#[test]
fn generated_support_map_contains_only_real_catalogue_history() {
assert_eq!(
support_header_value(),
"decision_event=1-2,policy_bundle=1-2"
);
assert_eq!(baseline(POLICY_BUNDLE_FAMILY), Some(2));
assert_eq!(baseline(DECISION_EVENT_FAMILY), Some(2));
}
#[test]
fn v2_decision_facts_refuse_lossy_v1_lowering() {
assert!(!decision_event_can_write(
&serde_json::json!({"olverdict":"block","olresult":"blocked"}),
1
));
assert!(decision_event_can_write(
&serde_json::json!({"olverdict":"allow"}),
1
));
assert!(decision_event_can_write(
&serde_json::json!({"olverdict":"block","olresult":"blocked"}),
2
));
}
#[test]
fn compiled_policy_refuses_a_schema_one_acknowledgement() {
assert!(!policy_bundle_can_read(
&serde_json::json!({
"schema_version": 1,
"artifacts": [{"artifact_id": "would-be-lost"}]
}),
1
));
assert!(policy_bundle_can_read(
&serde_json::json!({"schema_version": 1, "artifacts": []}),
1
));
assert!(policy_bundle_can_read(
&serde_json::json!({"schema_version": 2, "artifacts": [{"artifact_id": "kept"}]}),
2
));
}
#[test]
fn first_boot_diagnostic_and_later_selection_are_sidecar_only() {
let dir = tempfile::tempdir().unwrap();
record_diagnostic(
dir.path(),
CompatibilityDiagnostic {
family: POLICY_BUNDLE_FAMILY.to_string(),
client_range: VersionRange::new(1, 2).unwrap(),
platform_range: Some(VersionRange::new(3, 4).unwrap()),
last_selection: None,
observed_at: now_rfc3339(),
detail: "no intersection".to_string(),
},
)
.unwrap();
let state = read_compatibility(dir.path()).unwrap();
assert!(state.diagnostics.contains_key(POLICY_BUNDLE_FAMILY));
assert!(!dir.path().join("policy/bundle.json").exists());
assert!(!dir.path().join("policy/bundle.meta.json").exists());
record_selection(dir.path(), POLICY_BUNDLE_FAMILY, 2).unwrap();
let state = read_compatibility(dir.path()).unwrap();
assert_eq!(state.selections[POLICY_BUNDLE_FAMILY].version, 2);
assert!(!state.diagnostics.contains_key(POLICY_BUNDLE_FAMILY));
}
}