Skip to main content

axon/runtime/
lease_kernel.rs

1//! AXON Runtime — LeaseKernel (§λ-L-E Fase 3.2)
2//!
3//! Direct port of `axon/runtime/lease_kernel.py`.
4//!
5//! τ-decay token manager implementing Decision D2:
6//!   * Compile-time: a lease references an `affine` or `linear` resource.
7//!     The type checker already rejected `persistent` leases (no τ to decay).
8//!   * Runtime: `acquire` emits a revocable `LeaseToken` with explicit τ
9//!     (`acquired_at`, `expires_at`). Post-expiry `use` raises
10//!     `LeaseExpired` (CT-2 Anchor Breach) unless a permissive policy is set.
11//!   * Policy: `on_expire ∈ {anchor_breach, release, extend}`.
12//!
13//! The kernel is an in-process registry; distributed coordination is
14//! deferred to a later phase.
15
16#![allow(dead_code)]
17
18use std::collections::{HashMap, HashSet};
19
20use chrono::{DateTime, Duration, Utc};
21use uuid::Uuid;
22
23use crate::handlers::base::{HandlerError, LambdaEnvelope};
24use crate::ir_nodes::{IRLease, IRResource};
25
26// ═══════════════════════════════════════════════════════════════════
27//  DURATION PARSING — "30s" | "5m" | "2h" | "12ms" | "1d"
28// ═══════════════════════════════════════════════════════════════════
29
30/// Convert an Axon duration literal into fractional seconds.
31///
32/// Raises a CT-1 (`HandlerError::callee`) on unparseable input because the
33/// parser already validated the syntax — a failure here is a runtime bug.
34pub fn parse_duration(text: &str) -> Result<f64, HandlerError> {
35    let trimmed = text.trim();
36    if trimmed.is_empty() {
37        return Err(HandlerError::callee("parse_duration called with empty string"));
38    }
39    // Split numeric prefix from unit suffix.
40    let split = trimmed.find(|c: char| !c.is_ascii_digit()).unwrap_or(trimmed.len());
41    if split == 0 {
42        return Err(HandlerError::callee(format!(
43            "unparseable duration literal: '{text}' (expected <int><ms|s|m|h|d>)"
44        )));
45    }
46    let (num_str, unit) = trimmed.split_at(split);
47    let unit = unit.trim_start();
48    let value: u64 = num_str.parse().map_err(|_| {
49        HandlerError::callee(format!("unparseable duration literal: '{text}'"))
50    })?;
51    let unit_secs = match unit {
52        "ms" => 0.001_f64,
53        "s" => 1.0,
54        "m" => 60.0,
55        "h" => 3600.0,
56        "d" => 86400.0,
57        _ => {
58            return Err(HandlerError::callee(format!(
59                "unparseable duration literal: '{text}' (expected <int><ms|s|m|h|d>)"
60            )));
61        }
62    };
63    Ok(value as f64 * unit_secs)
64}
65
66// ═══════════════════════════════════════════════════════════════════
67//  LEASE TOKEN — the τ-decaying affine capability
68// ═══════════════════════════════════════════════════════════════════
69
70/// A single-use capability over a resource, valid only while τ is in the
71/// `[acquired_at, expires_at)` window. The token is effectively frozen —
72/// extension is implemented by minting a new token and revoking the old,
73/// preserving the linearity invariant.
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub struct LeaseToken {
76    pub token_id: String,
77    pub lease_name: String,
78    pub resource_ref: String,
79    pub acquired_at: DateTime<Utc>,
80    pub expires_at: DateTime<Utc>,
81    pub on_expire: String,
82}
83
84impl LeaseToken {
85    /// Current ΛD envelope — c decays to 0.0 when τ expires.
86    pub fn envelope(&self, now: DateTime<Utc>) -> LambdaEnvelope {
87        if now >= self.expires_at {
88            LambdaEnvelope::new(
89                0.0,
90                now.to_rfc3339(),
91                "lease_kernel".into(),
92                "observed".into(),
93            )
94        } else {
95            LambdaEnvelope::new(
96                1.0,
97                self.acquired_at.to_rfc3339(),
98                "lease_kernel".into(),
99                "axiomatic".into(),
100            )
101        }
102    }
103
104    /// Remaining window in seconds (0 if already expired).
105    pub fn remaining_seconds(&self, now: DateTime<Utc>) -> f64 {
106        let delta = self.expires_at.signed_duration_since(now);
107        let secs = delta.num_milliseconds() as f64 / 1000.0;
108        if secs < 0.0 { 0.0 } else { secs }
109    }
110}
111
112// ═══════════════════════════════════════════════════════════════════
113//  LEASE KERNEL
114// ═══════════════════════════════════════════════════════════════════
115
116/// Pluggable wall-clock. Defaults to `Utc::now`; tests inject a controllable
117/// clock to verify τ-decay without sleeping.
118pub type Clock = Box<dyn Fn() -> DateTime<Utc> + Send>;
119
120/// Return value from `LeaseKernel::use_token`:
121///   * `Valid(token)` — the same token is still inside its τ window.
122///   * `Extended(new)` — on_expire="extend" minted a fresh token.
123///   * `Released` — on_expire="release" silently retired the lease.
124#[derive(Debug, Clone)]
125pub enum UseOutcome {
126    Valid(LeaseToken),
127    Extended(LeaseToken),
128    Released,
129}
130
131/// In-process registry of active leases.
132pub struct LeaseKernel {
133    tokens: HashMap<String, LeaseToken>,
134    revoked: HashSet<String>,
135    clock: Clock,
136}
137
138impl LeaseKernel {
139    pub fn new() -> Self {
140        LeaseKernel {
141            tokens: HashMap::new(),
142            revoked: HashSet::new(),
143            clock: Box::new(Utc::now),
144        }
145    }
146
147    pub fn with_clock(clock: Clock) -> Self {
148        LeaseKernel { tokens: HashMap::new(), revoked: HashSet::new(), clock }
149    }
150
151    /// Mint a fresh token for a lease against a resource. Rejects persistent
152    /// resources (defence in depth — the type-checker already did this).
153    pub fn acquire(
154        &mut self,
155        ir_lease: &IRLease,
156        ir_resource: &IRResource,
157    ) -> Result<LeaseToken, HandlerError> {
158        if ir_resource.lifetime == "persistent" {
159            return Err(HandlerError::caller(format!(
160                "lease '{}' cannot target persistent resource '{}' — \
161                 persistent (!A) is unbounded, it has no τ to decay.",
162                ir_lease.name, ir_resource.name
163            )));
164        }
165        if ir_lease.resource_ref != ir_resource.name {
166            return Err(HandlerError::callee(format!(
167                "acquire called with mismatched resource: lease.resource_ref={:?}, \
168                 ir_resource.name={:?}",
169                ir_lease.resource_ref, ir_resource.name
170            )));
171        }
172        let seconds = parse_duration(&ir_lease.duration)?;
173        let now = (self.clock)();
174        let millis = (seconds * 1000.0) as i64;
175        let token = LeaseToken {
176            token_id: format!("lease-{}", &Uuid::new_v4().simple().to_string()[..12]),
177            lease_name: ir_lease.name.clone(),
178            resource_ref: ir_resource.name.clone(),
179            acquired_at: now,
180            expires_at: now + Duration::milliseconds(millis),
181            on_expire: ir_lease.on_expire.clone(),
182        };
183        self.tokens.insert(token.token_id.clone(), token.clone());
184        Ok(token)
185    }
186
187    /// Verify the token is still valid and apply `on_expire` policy on decay.
188    pub fn use_token(&mut self, token: &LeaseToken) -> Result<UseOutcome, HandlerError> {
189        if self.revoked.contains(&token.token_id) {
190            return Err(HandlerError::caller(format!(
191                "lease token '{}' was revoked (lease='{}')",
192                token.token_id, token.lease_name
193            )));
194        }
195        if !self.tokens.contains_key(&token.token_id) {
196            return Err(HandlerError::caller(format!(
197                "unknown lease token '{}' (lease='{}') — did you forget to acquire?",
198                token.token_id, token.lease_name
199            )));
200        }
201        let now = (self.clock)();
202        if now < token.expires_at {
203            return Ok(UseOutcome::Valid(token.clone()));
204        }
205        match token.on_expire.as_str() {
206            "anchor_breach" => Err(HandlerError::lease_expired(format!(
207                "lease '{}' on resource '{}' expired at {} \
208                 (Anchor Breach — Decision D2, CT-2)",
209                token.lease_name, token.resource_ref,
210                token.expires_at.to_rfc3339()
211            ))),
212            "release" => {
213                self.tokens.remove(&token.token_id);
214                Ok(UseOutcome::Released)
215            }
216            "extend" => {
217                // Preserve the original Δt window; mint a fresh token and
218                // revoke the old so linearity of the lease-name mapping holds.
219                let duration = token.expires_at.signed_duration_since(token.acquired_at);
220                let renewed = LeaseToken {
221                    token_id: format!("lease-{}", &Uuid::new_v4().simple().to_string()[..12]),
222                    lease_name: token.lease_name.clone(),
223                    resource_ref: token.resource_ref.clone(),
224                    acquired_at: now,
225                    expires_at: now + duration,
226                    on_expire: token.on_expire.clone(),
227                };
228                self.revoked.insert(token.token_id.clone());
229                self.tokens.remove(&token.token_id);
230                self.tokens.insert(renewed.token_id.clone(), renewed.clone());
231                Ok(UseOutcome::Extended(renewed))
232            }
233            other => Err(HandlerError::callee(format!(
234                "unknown on_expire policy '{other}' (token id='{}')",
235                token.token_id
236            ))),
237        }
238    }
239
240    /// Explicitly revoke a token. Idempotent.
241    pub fn release(&mut self, token: &LeaseToken) {
242        self.revoked.insert(token.token_id.clone());
243        self.tokens.remove(&token.token_id);
244    }
245
246    /// Purge tokens whose τ has elapsed. Returns the removed tokens.
247    pub fn sweep(&mut self) -> Vec<LeaseToken> {
248        let now = (self.clock)();
249        let expired: Vec<LeaseToken> = self
250            .tokens
251            .values()
252            .filter(|t| now >= t.expires_at)
253            .cloned()
254            .collect();
255        for t in &expired {
256            self.tokens.remove(&t.token_id);
257        }
258        expired
259    }
260
261    /// Snapshot of currently-valid tokens.
262    pub fn active(&self) -> Vec<LeaseToken> {
263        let now = (self.clock)();
264        self.tokens.values().filter(|t| now < t.expires_at).cloned().collect()
265    }
266
267    pub fn contains(&self, token_id: &str) -> bool {
268        self.tokens.contains_key(token_id)
269    }
270}
271
272impl Default for LeaseKernel {
273    fn default() -> Self { Self::new() }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::handlers::base::HandlerErrorKind;
280    use std::cell::Cell;
281    use std::sync::{Arc, Mutex};
282
283    fn mk_resource(name: &str, lifetime: &str) -> IRResource {
284        IRResource {
285            node_type: "resource",
286            source_line: 1,
287            source_column: 1,
288            name: name.into(),
289            kind: "postgres".into(),
290            endpoint: String::new(),
291            capacity: None,
292            lifetime: lifetime.into(),
293            certainty_floor: None,
294            shield_ref: String::new(),
295        }
296    }
297
298    fn mk_lease(name: &str, resource_ref: &str, duration: &str, on_expire: &str) -> IRLease {
299        IRLease {
300            node_type: "lease",
301            source_line: 1,
302            source_column: 1,
303            name: name.into(),
304            resource_ref: resource_ref.into(),
305            duration: duration.into(),
306            acquire: "on_start".into(),
307            on_expire: on_expire.into(),
308        }
309    }
310
311    /// A controllable clock for τ-decay tests. Uses interior mutability
312    /// to advance time without re-acquiring the kernel.
313    fn mock_clock() -> (Clock, Arc<Mutex<DateTime<Utc>>>) {
314        let start: DateTime<Utc> = "2026-04-20T12:00:00Z".parse().unwrap();
315        let state = Arc::new(Mutex::new(start));
316        let c = state.clone();
317        let clock: Clock = Box::new(move || *c.lock().unwrap());
318        (clock, state)
319    }
320
321    #[test]
322    fn parse_duration_handles_all_units() {
323        assert!((parse_duration("500ms").unwrap() - 0.5).abs() < 1e-9);
324        assert_eq!(parse_duration("30s").unwrap(), 30.0);
325        assert_eq!(parse_duration("5m").unwrap(), 300.0);
326        assert_eq!(parse_duration("2h").unwrap(), 7200.0);
327        assert_eq!(parse_duration("1d").unwrap(), 86400.0);
328    }
329
330    #[test]
331    fn parse_duration_rejects_garbage() {
332        assert_eq!(parse_duration("").unwrap_err().kind, HandlerErrorKind::Callee);
333        assert_eq!(parse_duration("30y").unwrap_err().kind, HandlerErrorKind::Callee);
334        assert_eq!(parse_duration("forever").unwrap_err().kind, HandlerErrorKind::Callee);
335    }
336
337    #[test]
338    fn acquire_mints_valid_token() {
339        let mut k = LeaseKernel::new();
340        let r = mk_resource("Db", "linear");
341        let l = mk_lease("L", "Db", "30s", "anchor_breach");
342        let tok = k.acquire(&l, &r).unwrap();
343        assert!(tok.token_id.starts_with("lease-"));
344        assert_eq!(tok.lease_name, "L");
345        assert_eq!(tok.resource_ref, "Db");
346        assert!(k.contains(&tok.token_id));
347    }
348
349    #[test]
350    fn acquire_rejects_persistent_resource() {
351        let mut k = LeaseKernel::new();
352        let r = mk_resource("Shared", "persistent");
353        let l = mk_lease("L", "Shared", "30s", "anchor_breach");
354        let err = k.acquire(&l, &r).unwrap_err();
355        assert_eq!(err.kind, HandlerErrorKind::Caller);
356    }
357
358    #[test]
359    fn use_before_expiry_returns_same_token() {
360        let mut k = LeaseKernel::new();
361        let r = mk_resource("Db", "affine");
362        let l = mk_lease("L", "Db", "30s", "anchor_breach");
363        let tok = k.acquire(&l, &r).unwrap();
364        match k.use_token(&tok).unwrap() {
365            UseOutcome::Valid(t) => assert_eq!(t.token_id, tok.token_id),
366            other => panic!("expected Valid, got {other:?}"),
367        }
368    }
369
370    #[test]
371    fn anchor_breach_policy_raises_after_tau_decay() {
372        let (clock, state) = mock_clock();
373        let mut k = LeaseKernel::with_clock(clock);
374        let r = mk_resource("Db", "linear");
375        let l = mk_lease("L", "Db", "1s", "anchor_breach");
376        let tok = k.acquire(&l, &r).unwrap();
377        // Advance past expiry.
378        *state.lock().unwrap() += Duration::seconds(2);
379        let err = k.use_token(&tok).unwrap_err();
380        assert_eq!(err.kind, HandlerErrorKind::LeaseExpired);
381        assert_eq!(err.blame, "CT-2");
382    }
383
384    #[test]
385    fn release_policy_silently_retires_after_decay() {
386        let (clock, state) = mock_clock();
387        let mut k = LeaseKernel::with_clock(clock);
388        let r = mk_resource("Db", "linear");
389        let l = mk_lease("L", "Db", "1s", "release");
390        let tok = k.acquire(&l, &r).unwrap();
391        *state.lock().unwrap() += Duration::seconds(2);
392        let outcome = k.use_token(&tok).unwrap();
393        assert!(matches!(outcome, UseOutcome::Released));
394        assert!(!k.contains(&tok.token_id));
395    }
396
397    #[test]
398    fn extend_policy_mints_fresh_token_and_revokes_old() {
399        let (clock, state) = mock_clock();
400        let mut k = LeaseKernel::with_clock(clock);
401        let r = mk_resource("Db", "linear");
402        let l = mk_lease("L", "Db", "1s", "extend");
403        let tok = k.acquire(&l, &r).unwrap();
404        let first_id = tok.token_id.clone();
405        *state.lock().unwrap() += Duration::seconds(2);
406        let outcome = k.use_token(&tok).unwrap();
407        match outcome {
408            UseOutcome::Extended(new_tok) => {
409                assert_ne!(new_tok.token_id, first_id);
410                assert_eq!(new_tok.lease_name, "L");
411                // Old token is revoked, so using it again should error.
412                let err = k.use_token(&tok).unwrap_err();
413                assert_eq!(err.kind, HandlerErrorKind::Caller);
414            }
415            other => panic!("expected Extended, got {other:?}"),
416        }
417    }
418
419    #[test]
420    fn release_is_idempotent() {
421        let mut k = LeaseKernel::new();
422        let r = mk_resource("Db", "linear");
423        let l = mk_lease("L", "Db", "30s", "release");
424        let tok = k.acquire(&l, &r).unwrap();
425        k.release(&tok);
426        k.release(&tok); // second call must not panic
427        assert!(!k.contains(&tok.token_id));
428    }
429
430    #[test]
431    fn sweep_removes_only_expired_tokens() {
432        let (clock, state) = mock_clock();
433        let mut k = LeaseKernel::with_clock(clock);
434        let r = mk_resource("Db", "affine");
435        let l_short = mk_lease("S", "Db", "1s", "release");
436        let l_long = mk_lease("L", "Db", "1h", "release");
437        let s = k.acquire(&l_short, &r).unwrap();
438        let _l = k.acquire(&l_long, &r).unwrap();
439        *state.lock().unwrap() += Duration::seconds(2);
440        let expired = k.sweep();
441        assert_eq!(expired.len(), 1);
442        assert_eq!(expired[0].token_id, s.token_id);
443        assert_eq!(k.active().len(), 1);
444    }
445
446    #[test]
447    fn envelope_decays_to_zero_after_expiry() {
448        let start: DateTime<Utc> = "2026-04-20T12:00:00Z".parse().unwrap();
449        let tok = LeaseToken {
450            token_id: "lease-x".into(),
451            lease_name: "L".into(),
452            resource_ref: "Db".into(),
453            acquired_at: start,
454            expires_at: start + Duration::seconds(30),
455            on_expire: "anchor_breach".into(),
456        };
457        assert_eq!(tok.envelope(start).c, 1.0);
458        assert_eq!(tok.envelope(start + Duration::seconds(60)).c, 0.0);
459    }
460
461    // Keeps the `Cell` import alive if future tests need it.
462    #[allow(dead_code)]
463    fn _unused_cell_probe() { let _ = Cell::new(0u32); }
464}