use std::future::Future;
use std::pin::Pin;
use crate::hash_chain::ChainHash;
pub const ANCHOR_KEY_PREFIX: &str = "ZEPH_HISTORY_ANCHOR_";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AnchorSubsystem {
SubagentTranscript,
SessionLog,
}
impl AnchorSubsystem {
#[must_use]
pub const fn key_segment(self) -> &'static str {
match self {
Self::SubagentTranscript => "SUBAGENT",
Self::SessionLog => "SESSION",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Anchor {
pub version: u8,
pub epoch: u32,
pub count: u64,
pub head_hex: String,
pub written_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub orphaned_since: Option<u64>,
}
pub const ANCHOR_VERSION: u8 = 1;
impl Anchor {
#[must_use]
pub fn new(epoch: u32, count: u64, head: ChainHash) -> Self {
Self {
version: ANCHOR_VERSION,
epoch,
count,
head_hex: head.to_hex(),
written_at: now_unix_millis(),
orphaned_since: None,
}
}
pub fn head(&self) -> Result<ChainHash, AnchorError> {
ChainHash::from_hex(&self.head_hex).map_err(|_| AnchorError::Malformed)
}
}
#[must_use]
pub fn now_unix_millis() -> u64 {
u64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(u64::MAX)
}
#[derive(Debug, thiserror::Error)]
pub enum AnchorError {
#[error("anchor store I/O failed: {0}")]
Store(String),
#[error("stored anchor is malformed")]
Malformed,
}
pub trait AnchorStore: Send + Sync {
fn get(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Pin<Box<dyn Future<Output = Result<Option<Anchor>, AnchorError>> + Send + '_>>;
fn get_sync(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Result<Option<Anchor>, AnchorError>;
fn put(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
anchor: Anchor,
) -> Pin<Box<dyn Future<Output = Result<(), AnchorError>> + Send + '_>>;
fn delete(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Pin<Box<dyn Future<Output = Result<(), AnchorError>> + Send + '_>>;
}
fn encode_file_id(file_id: &[u8]) -> String {
use std::fmt::Write as _;
let safe = file_id
.iter()
.all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
if safe {
String::from_utf8_lossy(file_id).into_owned()
} else {
let mut out = String::with_capacity(4 + file_id.len() * 2);
out.push_str("hex:");
for b in file_id {
let _ = write!(out, "{b:02x}");
}
out
}
}
fn decode_file_id(segment: &str) -> Option<Vec<u8>> {
if let Some(hex) = segment.strip_prefix("hex:") {
if hex.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(hex.len() / 2);
let bytes = hex.as_bytes();
for chunk in bytes.chunks(2) {
let hi = (chunk[0] as char).to_digit(16)?;
let lo = (chunk[1] as char).to_digit(16)?;
out.push(u8::try_from(hi * 16 + lo).ok()?);
}
Some(out)
} else {
Some(segment.as_bytes().to_vec())
}
}
#[must_use]
pub fn anchor_key(subsystem: AnchorSubsystem, file_id: &[u8]) -> String {
format!(
"{ANCHOR_KEY_PREFIX}{}_{}",
subsystem.key_segment(),
encode_file_id(file_id)
)
}
#[must_use]
pub fn parse_anchor_key(key: &str) -> Option<(AnchorSubsystem, Vec<u8>)> {
let rest = key.strip_prefix(ANCHOR_KEY_PREFIX)?;
let (subsystem, file_id_segment) = if let Some(id) = rest.strip_prefix("SUBAGENT_") {
(AnchorSubsystem::SubagentTranscript, id)
} else {
let id = rest.strip_prefix("SESSION_")?;
(AnchorSubsystem::SessionLog, id)
};
decode_file_id(file_id_segment).map(|id| (subsystem, id))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash_chain::{ChainKey, chain_next, genesis};
fn sample_head() -> ChainHash {
let key = ChainKey::new([1u8; 32]);
let base = genesis(&key, "d", b"f", 0);
chain_next(&key, &base, b"content")
}
#[test]
fn anchor_key_round_trips_for_safe_ids() {
let key = anchor_key(AnchorSubsystem::SubagentTranscript, b"abc-123.task");
assert_eq!(key, "ZEPH_HISTORY_ANCHOR_SUBAGENT_abc-123.task");
let (subsystem, id) = parse_anchor_key(&key).unwrap();
assert_eq!(subsystem, AnchorSubsystem::SubagentTranscript);
assert_eq!(id, b"abc-123.task");
}
#[test]
fn anchor_key_round_trips_for_unsafe_bytes() {
let file_id = vec![0xffu8, 0x00, b'/'];
let key = anchor_key(AnchorSubsystem::SessionLog, &file_id);
assert!(key.starts_with("ZEPH_HISTORY_ANCHOR_SESSION_hex:"));
let (subsystem, id) = parse_anchor_key(&key).unwrap();
assert_eq!(subsystem, AnchorSubsystem::SessionLog);
assert_eq!(id, file_id);
}
#[test]
fn parse_anchor_key_rejects_unrelated_keys() {
assert!(parse_anchor_key("ZEPH_OPENAI_API_KEY").is_none());
assert!(parse_anchor_key("ZEPH_HISTORY_ANCHOR_BOGUS_x").is_none());
}
#[test]
fn anchor_new_stamps_written_at_and_round_trips_head() {
let head = sample_head();
let anchor = Anchor::new(3, 42, head);
assert_eq!(anchor.version, ANCHOR_VERSION);
assert_eq!(anchor.epoch, 3);
assert_eq!(anchor.count, 42);
assert!(anchor.written_at > 0);
assert_eq!(anchor.head().unwrap(), head);
}
#[test]
fn anchor_serializes_to_json_and_back() {
let anchor = Anchor::new(0, 7, sample_head());
let json = serde_json::to_string(&anchor).unwrap();
let round_tripped: Anchor = serde_json::from_str(&json).unwrap();
assert_eq!(round_tripped.count, 7);
assert_eq!(round_tripped.head_hex, anchor.head_hex);
assert_eq!(round_tripped.written_at, anchor.written_at);
assert_eq!(round_tripped.orphaned_since, None);
}
#[test]
fn anchor_new_omits_orphaned_since_from_serialized_json() {
let anchor = Anchor::new(0, 1, sample_head());
let json = serde_json::to_string(&anchor).unwrap();
assert!(!json.contains("orphaned_since"));
}
#[test]
fn anchor_deserializes_legacy_json_without_orphaned_since_field() {
let legacy_json =
r#"{"version":1,"epoch":0,"count":3,"head_hex":"ab12","written_at":1000}"#;
let anchor: Anchor = serde_json::from_str(legacy_json).unwrap();
assert_eq!(anchor.orphaned_since, None);
}
#[test]
fn anchor_round_trips_orphaned_since_when_set() {
let mut anchor = Anchor::new(0, 7, sample_head());
anchor.orphaned_since = Some(123_456);
let json = serde_json::to_string(&anchor).unwrap();
assert!(json.contains("orphaned_since"));
let round_tripped: Anchor = serde_json::from_str(&json).unwrap();
assert_eq!(round_tripped.orphaned_since, Some(123_456));
}
#[test]
fn anchor_head_rejects_malformed_hex() {
let mut anchor = Anchor::new(0, 1, sample_head());
anchor.head_hex = "not-hex".to_owned();
assert!(matches!(anchor.head(), Err(AnchorError::Malformed)));
}
}