Skip to main content

lean_ctx/core/
agent_lease.rs

1//! Bounded, payload-free ownership leases for multi-agent mutation planning.
2//!
3//! Gives callers an idempotent path/symbol ownership primitive. Does not perform
4//! a mutation, resolve a path, or authorize an agent; policy and transport bind
5//! it later. Uses caller-provided time for deterministic expiry boundaries.
6
7use std::collections::BTreeMap;
8use std::sync::Mutex;
9
10use serde::{Deserialize, Serialize};
11
12pub const AGENT_LEASE_SCHEMA_VERSION: u16 = 1;
13const DEFAULT_MAX_LEASES: usize = 1_024;
14const MAX_LEASE_DURATION_MS: u64 = 60 * 60 * 1_000;
15
16#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AgentLeaseResourceKindV1 {
19    Path,
20    Symbol,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct AgentLeaseRequestV1 {
26    pub schema_version: u16,
27    pub lease_request_ref: String,
28    pub resource_kind: AgentLeaseResourceKindV1,
29    pub resource_ref: String,
30    pub owner_agent_id: String,
31    pub duration_ms: u64,
32}
33
34impl AgentLeaseRequestV1 {
35    pub fn validate(&self) -> Result<(), AgentLeaseError> {
36        if self.schema_version != AGENT_LEASE_SCHEMA_VERSION {
37            return Err(AgentLeaseError::UnsupportedVersion(self.schema_version));
38        }
39        opaque_ref("lease_request_ref", &self.lease_request_ref)?;
40        opaque_ref("resource_ref", &self.resource_ref)?;
41        agent_id(&self.owner_agent_id)?;
42        if self.duration_ms == 0 || self.duration_ms > MAX_LEASE_DURATION_MS {
43            return Err(AgentLeaseError::Invalid(format!(
44                "duration_ms must be between 1 and {MAX_LEASE_DURATION_MS}"
45            )));
46        }
47        Ok(())
48    }
49}
50
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct AgentLeaseV1 {
54    pub schema_version: u16,
55    pub lease_ref: String,
56    pub request: AgentLeaseRequestV1,
57    pub expires_at_epoch_ms: u64,
58}
59
60impl AgentLeaseV1 {
61    pub fn is_active_at(&self, now_epoch_ms: u64) -> bool {
62        now_epoch_ms < self.expires_at_epoch_ms
63    }
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub enum AgentLeaseAcquireV1 {
68    Granted(AgentLeaseV1),
69    HeldBy {
70        owner_agent_id: String,
71        lease_ref: String,
72        expires_at_epoch_ms: u64,
73    },
74}
75
76/// Local registry with caller-provided clock for deterministic tests.
77pub struct AgentLeaseRegistryV1 {
78    leases: BTreeMap<(AgentLeaseResourceKindV1, String), AgentLeaseV1>,
79    max_leases: usize,
80}
81
82impl Default for AgentLeaseRegistryV1 {
83    fn default() -> Self {
84        Self::new(DEFAULT_MAX_LEASES)
85    }
86}
87
88impl AgentLeaseRegistryV1 {
89    #[must_use]
90    pub fn new(max_leases: usize) -> Self {
91        Self {
92            leases: BTreeMap::new(),
93            max_leases: max_leases.max(1),
94        }
95    }
96
97    pub fn acquire(
98        &mut self,
99        request: AgentLeaseRequestV1,
100        now_epoch_ms: u64,
101    ) -> Result<AgentLeaseAcquireV1, AgentLeaseError> {
102        request.validate()?;
103        self.remove_expired(now_epoch_ms);
104        let key = (request.resource_kind, request.resource_ref.clone());
105        if let Some(existing) = self.leases.get(&key) {
106            if existing.request.owner_agent_id == request.owner_agent_id
107                && existing.request.lease_request_ref == request.lease_request_ref
108            {
109                return Ok(AgentLeaseAcquireV1::Granted(existing.clone()));
110            }
111            return Ok(AgentLeaseAcquireV1::HeldBy {
112                owner_agent_id: existing.request.owner_agent_id.clone(),
113                lease_ref: existing.lease_ref.clone(),
114                expires_at_epoch_ms: existing.expires_at_epoch_ms,
115            });
116        }
117        if self.leases.len() >= self.max_leases {
118            return Err(AgentLeaseError::CapacityExceeded(self.max_leases));
119        }
120        let expires_at_epoch_ms = now_epoch_ms.saturating_add(request.duration_ms);
121        let lease_ref = compute_lease_ref(&request, expires_at_epoch_ms)?;
122        let lease = AgentLeaseV1 {
123            schema_version: AGENT_LEASE_SCHEMA_VERSION,
124            lease_ref,
125            request,
126            expires_at_epoch_ms,
127        };
128        self.leases.insert(key, lease.clone());
129        Ok(AgentLeaseAcquireV1::Granted(lease))
130    }
131
132    pub fn release(
133        &mut self,
134        resource_kind: AgentLeaseResourceKindV1,
135        resource_ref: &str,
136        owner_agent_id: &str,
137        lease_ref: &str,
138        now_epoch_ms: u64,
139    ) -> Result<bool, AgentLeaseError> {
140        opaque_ref("resource_ref", resource_ref)?;
141        agent_id(owner_agent_id)?;
142        self.remove_expired(now_epoch_ms);
143        let key = (resource_kind, resource_ref.to_string());
144        let Some(existing) = self.leases.get(&key) else {
145            return Ok(false);
146        };
147        if existing.request.owner_agent_id != owner_agent_id || existing.lease_ref != lease_ref {
148            return Err(AgentLeaseError::NotOwner);
149        }
150        self.leases.remove(&key);
151        Ok(true)
152    }
153
154    #[must_use]
155    pub fn active_count(&self, now_epoch_ms: u64) -> usize {
156        self.leases
157            .values()
158            .filter(|l| l.is_active_at(now_epoch_ms))
159            .count()
160    }
161
162    fn remove_expired(&mut self, now_epoch_ms: u64) {
163        self.leases.retain(|_, l| l.is_active_at(now_epoch_ms));
164    }
165}
166
167// ─── Global Process-Local Registry ──────────────────────────────────────────
168
169static GLOBAL_REGISTRY: std::sync::OnceLock<Mutex<AgentLeaseRegistryV1>> =
170    std::sync::OnceLock::new();
171
172fn global_registry() -> &'static Mutex<AgentLeaseRegistryV1> {
173    GLOBAL_REGISTRY.get_or_init(|| Mutex::new(AgentLeaseRegistryV1::default()))
174}
175
176fn now_epoch_ms() -> u64 {
177    std::time::SystemTime::now()
178        .duration_since(std::time::UNIX_EPOCH)
179        .unwrap_or_default()
180        .as_millis() as u64
181}
182
183pub fn acquire_local(request: AgentLeaseRequestV1) -> Result<AgentLeaseAcquireV1, AgentLeaseError> {
184    let mut reg = global_registry()
185        .lock()
186        .unwrap_or_else(std::sync::PoisonError::into_inner);
187    reg.acquire(request, now_epoch_ms())
188}
189
190pub fn release_local(
191    resource_kind: AgentLeaseResourceKindV1,
192    resource_ref: &str,
193    owner_agent_id: &str,
194    lease_ref: &str,
195) -> Result<bool, AgentLeaseError> {
196    let mut reg = global_registry()
197        .lock()
198        .unwrap_or_else(std::sync::PoisonError::into_inner);
199    reg.release(
200        resource_kind,
201        resource_ref,
202        owner_agent_id,
203        lease_ref,
204        now_epoch_ms(),
205    )
206}
207
208// ─── Helpers ─────────────────────────────────────────────────────────────────
209
210fn compute_lease_ref(
211    request: &AgentLeaseRequestV1,
212    expires_at_epoch_ms: u64,
213) -> Result<String, AgentLeaseError> {
214    let bytes = serde_json::to_vec(&(request, expires_at_epoch_ms))
215        .map_err(|e| AgentLeaseError::Serialize(e.to_string()))?;
216    Ok(format!("lease:{}", blake3::hash(&bytes).to_hex()))
217}
218
219fn opaque_ref(label: &str, value: &str) -> Result<(), AgentLeaseError> {
220    let (scheme, identifier) = value.split_once(':').ok_or_else(|| {
221        AgentLeaseError::Invalid(format!("{label} must use scheme:identifier form"))
222    })?;
223    let scheme_valid = !scheme.is_empty()
224        && scheme
225            .bytes()
226            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
227    let identifier_valid = !identifier.is_empty()
228        && value.len() <= 256
229        && identifier.bytes().all(|b| b.is_ascii_graphic());
230    (scheme_valid && identifier_valid)
231        .then_some(())
232        .ok_or_else(|| AgentLeaseError::Invalid(format!("invalid {label}")))
233}
234
235fn agent_id(value: &str) -> Result<(), AgentLeaseError> {
236    (!value.is_empty() && value.len() <= 256 && value.bytes().all(|b| b.is_ascii_graphic()))
237        .then_some(())
238        .ok_or_else(|| AgentLeaseError::Invalid("invalid owner_agent_id".into()))
239}
240
241// ─── Errors ──────────────────────────────────────────────────────────────────
242
243#[derive(Debug, thiserror::Error)]
244pub enum AgentLeaseError {
245    #[error("unsupported schema version {0}")]
246    UnsupportedVersion(u16),
247    #[error("invalid lease: {0}")]
248    Invalid(String),
249    #[error("registry at capacity {0}")]
250    CapacityExceeded(usize),
251    #[error("not owner or mismatched lease_ref")]
252    NotOwner,
253    #[error("serialization failed: {0}")]
254    Serialize(String),
255}
256
257// ─── Tests ───────────────────────────────────────────────────────────────────
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn request(owner: &str, ref_id: &str) -> AgentLeaseRequestV1 {
264        AgentLeaseRequestV1 {
265            schema_version: AGENT_LEASE_SCHEMA_VERSION,
266            lease_request_ref: ref_id.to_string(),
267            resource_kind: AgentLeaseResourceKindV1::Path,
268            resource_ref: "pathref:src-core-main".to_string(),
269            owner_agent_id: owner.to_string(),
270            duration_ms: 100,
271        }
272    }
273
274    #[test]
275    fn idempotent_grant_blocks_foreign_owner() {
276        let mut reg = AgentLeaseRegistryV1::default();
277        let AgentLeaseAcquireV1::Granted(granted) =
278            reg.acquire(request("agent-a", "request:a"), 10).unwrap()
279        else {
280            panic!("expected grant")
281        };
282        assert_eq!(
283            reg.acquire(request("agent-a", "request:a"), 20).unwrap(),
284            AgentLeaseAcquireV1::Granted(granted)
285        );
286        assert!(matches!(
287            reg.acquire(request("agent-b", "request:b"), 20),
288            Ok(AgentLeaseAcquireV1::HeldBy { owner_agent_id, .. }) if owner_agent_id == "agent-a"
289        ));
290    }
291
292    #[test]
293    fn expiry_frees_resource() {
294        let mut reg = AgentLeaseRegistryV1::default();
295        let _ = reg.acquire(request("agent-a", "request:a"), 10).unwrap();
296        assert!(matches!(
297            reg.acquire(request("agent-b", "request:b"), 111),
298            Ok(AgentLeaseAcquireV1::Granted(_))
299        ));
300    }
301
302    #[test]
303    fn release_requires_owner() {
304        let mut reg = AgentLeaseRegistryV1::default();
305        let AgentLeaseAcquireV1::Granted(granted) =
306            reg.acquire(request("agent-a", "request:a"), 10).unwrap()
307        else {
308            panic!("expected grant")
309        };
310        assert!(
311            reg.release(
312                AgentLeaseResourceKindV1::Path,
313                "pathref:src-core-main",
314                "agent-b",
315                &granted.lease_ref,
316                20
317            )
318            .is_err()
319        );
320        assert!(
321            reg.release(
322                AgentLeaseResourceKindV1::Path,
323                "pathref:src-core-main",
324                "agent-a",
325                &granted.lease_ref,
326                20
327            )
328            .unwrap()
329        );
330    }
331
332    #[test]
333    fn capacity_enforced() {
334        let mut reg = AgentLeaseRegistryV1::new(1);
335        let _ = reg.acquire(request("agent-a", "request:a"), 1).unwrap();
336        let second = AgentLeaseRequestV1 {
337            resource_ref: "symbolref:main".to_string(),
338            resource_kind: AgentLeaseResourceKindV1::Symbol,
339            ..request("agent-b", "request:b")
340        };
341        assert!(matches!(
342            reg.acquire(second, 1),
343            Err(AgentLeaseError::CapacityExceeded(1))
344        ));
345    }
346}