Skip to main content

a3s_code_core/store/
lease.rs

1//! Writer lease fencing for session stores (KRN-6 / STORE-LEASE1).
2//!
3//! A durable lease epoch fences cross-process writers: after a takeover
4//! bumps the epoch, a stale holder cannot commit a newer snapshot generation.
5//! The lease never stores session plaintext — only holder identity and epoch.
6
7use anyhow::{bail, Result};
8use serde::{Deserialize, Serialize};
9
10pub const SESSION_STORE_WRITER_LEASE_SCHEMA_V1: &str = "a3s.code.session-store-writer-lease.v1";
11
12/// Durable writer lease published by a session store that advertises
13/// [`super::SessionStoreCapabilities::lease_fencing`].
14#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16pub struct SessionStoreWriterLeaseV1 {
17    pub schema: String,
18    /// Monotonic epoch; each successful acquire bumps by one.
19    pub epoch: u64,
20    /// Caller-supplied holder identity (process, host, or worker id).
21    pub holder_id: String,
22    pub acquired_at_ms: u64,
23}
24
25impl SessionStoreWriterLeaseV1 {
26    pub fn new(epoch: u64, holder_id: impl Into<String>, acquired_at_ms: u64) -> Result<Self> {
27        let lease = Self {
28            schema: SESSION_STORE_WRITER_LEASE_SCHEMA_V1.to_owned(),
29            epoch,
30            holder_id: holder_id.into(),
31            acquired_at_ms,
32        };
33        lease.validate()?;
34        Ok(lease)
35    }
36
37    pub fn validate(&self) -> Result<()> {
38        if self.schema != SESSION_STORE_WRITER_LEASE_SCHEMA_V1 {
39            bail!("session store writer lease schema is unsupported");
40        }
41        if self.epoch == 0 {
42            bail!("session store writer lease epoch must be one-based");
43        }
44        if self.holder_id.trim().is_empty() {
45            bail!("session store writer lease holder_id must be non-empty");
46        }
47        Ok(())
48    }
49
50    pub fn matches_holder(&self, other: &Self) -> bool {
51        self.epoch == other.epoch && self.holder_id == other.holder_id
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn writer_lease_rejects_empty_holder_and_zero_epoch() {
61        assert!(SessionStoreWriterLeaseV1::new(0, "a", 1).is_err());
62        assert!(SessionStoreWriterLeaseV1::new(1, "  ", 1).is_err());
63        let ok = SessionStoreWriterLeaseV1::new(1, "worker-a", 10).unwrap();
64        assert_eq!(ok.epoch, 1);
65        assert!(ok.matches_holder(&ok));
66    }
67}