Skip to main content

lean_ctx_protocol/hive/
lease.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Instance lease and the Hive signing key set.
4//!
5//! - `GET /v1/keys` → [`HiveKeySetV1`], signed by the Hive root key that is
6//!   compiled into the engine (self-host: `[hive] root_key`).
7//! - The lease arrives in enrollment and from `lease/renew` as
8//!   [`HiveLeaseEnvelopeV1`], signed by a key of the key set.
9//!
10//! A lease is `Valid` until `expires_at`, `Grace` until `grace_until`, then
11//! `Lapsed`. Local tools never depend on the lease; only Hive traffic does.
12
13use chrono::{DateTime, Duration, FixedOffset};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use super::{
18    KEYSET_SIGNATURE_DOMAIN, LEASE_SIGNATURE_DOMAIN, parse_time, require_schema, signing_input,
19    signing_input_without_signature, validate_b64_len, validate_id,
20};
21use crate::{UtcTimestamp, ValidationError};
22
23pub const LEASE_SCHEMA: &str = "leanctx.hive.lease/v1";
24pub const LEASE_ENVELOPE_SCHEMA: &str = "leanctx.hive.lease-envelope/v1";
25pub const KEYSET_SCHEMA: &str = "leanctx.hive.keyset/v1";
26
27/// Longest `expires_at - issued_at`.
28pub const MAX_LEASE_VALIDITY_HOURS: i64 = 24;
29/// Longest `grace_until - expires_at`.
30pub const MAX_LEASE_GRACE_DAYS: i64 = 7;
31/// A lease issued further than this in the future (after offset correction) is refused.
32pub const MAX_LEASE_FUTURE_SKEW_MINUTES: i64 = 15;
33/// The engine renews when less than this remains before `expires_at`.
34pub const LEASE_RENEW_BEFORE_HOURS: i64 = 6;
35
36/// Plan of the organization.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum HivePlan {
40    /// A workspace without a paid plan: Hive sends `none`.
41    #[serde(rename = "none")]
42    NoPlan,
43    Personal,
44    Team,
45    Enterprise,
46    #[serde(other)]
47    Unknown,
48}
49
50impl HivePlan {
51    /// The plan's name as people read it: Personal, Team and Enterprise.
52    pub fn display_name(self) -> &'static str {
53        match self {
54            Self::NoPlan => "No plan",
55            Self::Personal => "Personal",
56            Self::Team => "Team",
57            Self::Enterprise => "Enterprise",
58            Self::Unknown => "unknown",
59        }
60    }
61
62    /// The plan's product name: Personal, Team, Enterprise.
63    pub fn product_name(self) -> &'static str {
64        match self {
65            Self::NoPlan => "No plan",
66            Self::Personal => "Personal",
67            Self::Team => "Team",
68            Self::Enterprise => "Enterprise",
69            Self::Unknown => "Unknown",
70        }
71    }
72}
73
74/// A right granted to this instance. Unknown rights decode as `Unknown` and grant nothing.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76pub enum HiveRight {
77    #[serde(rename = "knowledge.read")]
78    KnowledgeRead,
79    #[serde(rename = "knowledge.draft")]
80    KnowledgeDraft,
81    #[serde(rename = "knowledge.merge")]
82    KnowledgeMerge,
83    #[serde(rename = "metrics.upload")]
84    MetricsUpload,
85    #[serde(rename = "receipts.upload")]
86    ReceiptsUpload,
87    #[serde(rename = "policy.pull")]
88    PolicyPull,
89    #[serde(rename = "bus.relay")]
90    BusRelay,
91    #[serde(rename = "bus.control")]
92    BusControl,
93    #[serde(rename = "sealed")]
94    Sealed,
95    /// 1.2: the Intelligence runtime may run for this instance (what it
96    /// enables stays gated by `plan`).
97    #[serde(rename = "runtime")]
98    Runtime,
99    /// 1.9: read the vault's tasks.
100    #[serde(rename = "tasks.read")]
101    TasksRead,
102    /// 1.9: claim tasks, report updates and submit results.
103    #[serde(rename = "tasks.work")]
104    TasksWork,
105    /// 1.9: propose tasks (a person approves them).
106    #[serde(rename = "tasks.propose")]
107    TasksPropose,
108    #[serde(other)]
109    Unknown,
110}
111
112/// Org policy signing key pinned by the lease (no trust on first use).
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct PolicySignerKeyV1 {
115    pub org: String,
116    /// Ed25519 public key, 32 bytes base64.
117    pub ed25519_pub: String,
118}
119
120/// The signed lease statement.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct HiveLeaseV1 {
123    pub schema: String,
124    pub lease_id: String,
125    pub org_id: String,
126    pub instance_id: String,
127    pub plan: HivePlan,
128    /// The organization's limit of engine instances (Personal 2,
129    /// Team 2 per seat, Enterprise 3 per seat and at least 25); the wire
130    /// name is frozen in v1.
131    pub seats_limit: u32,
132    pub rights: Vec<HiveRight>,
133    pub policy_signer_keys: Vec<PolicySignerKeyV1>,
134    pub issued_at: UtcTimestamp,
135    pub expires_at: UtcTimestamp,
136    pub grace_until: UtcTimestamp,
137    /// Service clock at signing; the engine keeps `server_time - local_now` as offset.
138    pub server_time: UtcTimestamp,
139    /// 1.4: what the organization allows its engines to upload. Absent
140    /// (an older service) allows both.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub org_settings: Option<HiveOrgSettingsV1>,
143}
144
145/// Organization-wide switches the lease carries (1.4). They only narrow:
146/// an engine uploads only what both its own `[hive]` setting and these
147/// allow, and receipts still need the `receipts.upload` right.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub struct HiveOrgSettingsV1 {
150    #[serde(default = "allowed")]
151    pub metrics_upload: bool,
152    #[serde(default = "allowed")]
153    pub receipts_upload: bool,
154}
155
156const fn allowed() -> bool {
157    true
158}
159
160/// Where a lease stands at a given (offset-corrected) time.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum LeaseState {
164    Valid,
165    Grace,
166    Lapsed,
167}
168
169impl HiveLeaseV1 {
170    pub fn validate(&self) -> Result<(), ValidationError> {
171        require_schema(&self.schema, LEASE_SCHEMA)?;
172        validate_id(&self.lease_id, "lease_id")?;
173        validate_id(&self.org_id, "org_id")?;
174        validate_id(&self.instance_id, "instance_id")?;
175        if self.seats_limit == 0 {
176            return Err(ValidationError::new("seats_limit must be positive"));
177        }
178        if self.rights.len() > 64 || self.policy_signer_keys.len() > 16 {
179            return Err(ValidationError::new("lease lists are too long"));
180        }
181        for key in &self.policy_signer_keys {
182            validate_id(&key.org, "policy_signer_keys.org")?;
183            validate_b64_len(&key.ed25519_pub, 32, "policy_signer_keys.ed25519_pub")?;
184        }
185        let issued = parse_time(&self.issued_at)?;
186        let expires = parse_time(&self.expires_at)?;
187        let grace = parse_time(&self.grace_until)?;
188        parse_time(&self.server_time)?;
189        if expires <= issued || expires - issued > Duration::hours(MAX_LEASE_VALIDITY_HOURS) {
190            return Err(ValidationError::new(
191                "expires_at must be after issued_at and at most 24 h later",
192            ));
193        }
194        if grace < expires || grace - expires > Duration::days(MAX_LEASE_GRACE_DAYS) {
195            return Err(ValidationError::new(
196                "grace_until must be within 7 days after expires_at",
197            ));
198        }
199        Ok(())
200    }
201
202    /// Whether the lease grants `right`.
203    pub fn grants(&self, right: HiveRight) -> bool {
204        right != HiveRight::Unknown && self.rights.contains(&right)
205    }
206
207    /// Whether the organization allows metrics uploads (absent settings allow).
208    pub fn org_allows_metrics_upload(&self) -> bool {
209        self.org_settings
210            .is_none_or(|settings| settings.metrics_upload)
211    }
212
213    /// Whether the organization allows receipt uploads (absent settings allow).
214    pub fn org_allows_receipts_upload(&self) -> bool {
215        self.org_settings
216            .is_none_or(|settings| settings.receipts_upload)
217    }
218
219    /// State at `now` (service time: local clock plus the stored offset).
220    /// A lease issued more than 15 minutes after `now` counts as `Lapsed`.
221    pub fn state_at(&self, now: DateTime<FixedOffset>) -> Result<LeaseState, ValidationError> {
222        let issued = parse_time(&self.issued_at)?;
223        if issued - now > Duration::minutes(MAX_LEASE_FUTURE_SKEW_MINUTES) {
224            return Ok(LeaseState::Lapsed);
225        }
226        Ok(if now < parse_time(&self.expires_at)? {
227            LeaseState::Valid
228        } else if now < parse_time(&self.grace_until)? {
229            LeaseState::Grace
230        } else {
231            LeaseState::Lapsed
232        })
233    }
234
235    /// Whether the engine should renew now.
236    pub fn renew_due(&self, now: DateTime<FixedOffset>) -> Result<bool, ValidationError> {
237        Ok(parse_time(&self.expires_at)? - now < Duration::hours(LEASE_RENEW_BEFORE_HOURS))
238    }
239}
240
241/// Lease as it travels: the raw lease object plus its signature.
242///
243/// `lease` stays raw JSON so the signature covers exactly what the service
244/// signed, including fields this engine does not know.
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246pub struct HiveLeaseEnvelopeV1 {
247    pub schema: String,
248    pub lease: Value,
249    /// Key id within [`HiveKeySetV1`].
250    pub kid: String,
251    /// Ed25519 signature (base64) over [`HiveLeaseEnvelopeV1::signing_input`].
252    pub signature: String,
253}
254
255impl HiveLeaseEnvelopeV1 {
256    /// Envelope fields only; call [`Self::lease`] after verifying the signature.
257    pub fn validate_shape(&self) -> Result<(), ValidationError> {
258        require_schema(&self.schema, LEASE_ENVELOPE_SCHEMA)?;
259        validate_id(&self.kid, "kid")?;
260        validate_b64_len(&self.signature, 64, "signature")?;
261        if !self.lease.is_object() {
262            return Err(ValidationError::new("lease must be a JSON object"));
263        }
264        Ok(())
265    }
266
267    /// `"leanctx.hive.lease.v1\n" || canonical_json(lease)`.
268    pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
269        signing_input(LEASE_SIGNATURE_DOMAIN, &self.lease)
270    }
271
272    /// Signing input for a lease the service is about to sign.
273    pub fn signing_input_for(lease: &HiveLeaseV1) -> Result<Vec<u8>, ValidationError> {
274        lease.validate()?;
275        signing_input(LEASE_SIGNATURE_DOMAIN, &lease_value(lease)?)
276    }
277
278    /// Build the envelope for a signed lease.
279    pub fn seal(
280        lease: &HiveLeaseV1,
281        kid: impl Into<String>,
282        signature: impl Into<String>,
283    ) -> Result<Self, ValidationError> {
284        lease.validate()?;
285        let envelope = Self {
286            schema: LEASE_ENVELOPE_SCHEMA.into(),
287            lease: lease_value(lease)?,
288            kid: kid.into(),
289            signature: signature.into(),
290        };
291        envelope.validate_shape()?;
292        Ok(envelope)
293    }
294
295    /// The typed, validated lease.
296    pub fn lease(&self) -> Result<HiveLeaseV1, ValidationError> {
297        let lease: HiveLeaseV1 = serde_json::from_value(self.lease.clone())
298            .map_err(|error| ValidationError::new(format!("lease: {error}")))?;
299        lease.validate()?;
300        Ok(lease)
301    }
302}
303
304fn lease_value(lease: &HiveLeaseV1) -> Result<Value, ValidationError> {
305    serde_json::to_value(lease).map_err(|error| ValidationError::new(format!("lease: {error}")))
306}
307
308/// One Hive signing key.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct HiveKeyV1 {
311    pub kid: String,
312    /// Ed25519 public key, 32 bytes base64.
313    pub ed25519_pub: String,
314    /// The key signs nothing after this time; signatures made before stay valid
315    /// until the signed object itself expires.
316    pub not_after: UtcTimestamp,
317}
318
319/// `GET /v1/keys`. The signature covers the object without `signature`.
320/// Engines keep the raw JSON (`keys.json`) and verify it with
321/// [`HiveKeySetV1::signing_input_raw`].
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323pub struct HiveKeySetV1 {
324    pub schema: String,
325    pub keys: Vec<HiveKeyV1>,
326    /// Ed25519 signature (base64) of the Hive root key.
327    pub signature: String,
328}
329
330impl HiveKeySetV1 {
331    pub fn validate(&self) -> Result<(), ValidationError> {
332        require_schema(&self.schema, KEYSET_SCHEMA)?;
333        if self.keys.is_empty() || self.keys.len() > 16 {
334            return Err(ValidationError::new("keys must list 1-16 keys"));
335        }
336        let mut seen = std::collections::BTreeSet::new();
337        for key in &self.keys {
338            validate_id(&key.kid, "kid")?;
339            validate_b64_len(&key.ed25519_pub, 32, "ed25519_pub")?;
340            parse_time(&key.not_after)?;
341            if !seen.insert(key.kid.as_str()) {
342                return Err(ValidationError::new("kid repeats in key set"));
343            }
344        }
345        validate_b64_len(&self.signature, 64, "signature")
346    }
347
348    /// Signing input of a key set as received.
349    pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
350        signing_input_without_signature(KEYSET_SIGNATURE_DOMAIN, raw)
351    }
352
353    /// Signing input of a key set the service is about to sign (`signature` ignored).
354    pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
355        let value = serde_json::to_value(self)
356            .map_err(|error| ValidationError::new(format!("keyset: {error}")))?;
357        Self::signing_input_raw(&value)
358    }
359
360    /// Key `kid`, if listed.
361    pub fn key(&self, kid: &str) -> Option<&HiveKeyV1> {
362        self.keys.iter().find(|key| key.kid == kid)
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn product_names_are_personal_team_enterprise() {
372        let names: Vec<&str> = [
373            HivePlan::NoPlan,
374            HivePlan::Personal,
375            HivePlan::Team,
376            HivePlan::Enterprise,
377        ]
378        .into_iter()
379        .map(HivePlan::product_name)
380        .collect();
381        assert_eq!(names, ["No plan", "Personal", "Team", "Enterprise"]);
382        // the wire name stays
383        assert_eq!(
384            serde_json::to_value(HivePlan::Enterprise).unwrap(),
385            serde_json::json!("enterprise")
386        );
387    }
388
389    fn lease() -> HiveLeaseV1 {
390        HiveLeaseV1 {
391            schema: LEASE_SCHEMA.into(),
392            lease_id: "lse_1".into(),
393            org_id: "org_1".into(),
394            instance_id: "inst_1".into(),
395            plan: HivePlan::Team,
396            seats_limit: 25,
397            rights: vec![HiveRight::KnowledgeRead],
398            policy_signer_keys: vec![],
399            issued_at: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
400            expires_at: UtcTimestamp::new("2026-10-21T12:00:00Z").unwrap(),
401            grace_until: UtcTimestamp::new("2026-10-28T12:00:00Z").unwrap(),
402            server_time: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
403            org_settings: None,
404        }
405    }
406
407    fn at(value: &str) -> DateTime<FixedOffset> {
408        DateTime::parse_from_rfc3339(value).unwrap()
409    }
410
411    /// Dogfood 25.10.: a personal workspace (plan `none`) showed "unknown
412    /// plan" at connect.
413    #[test]
414    fn plan_wire_ids_are_none_personal_team_enterprise() {
415        let plan = |s: &str| serde_json::from_value::<HivePlan>(serde_json::json!(s)).unwrap();
416        assert_eq!(plan("none"), HivePlan::NoPlan);
417        assert_eq!(plan("personal"), HivePlan::Personal);
418        assert_eq!(plan("none").display_name(), "No plan");
419        assert_eq!(plan("team").display_name(), "Team");
420        assert_eq!(plan("enterprise").display_name(), "Enterprise");
421        // The retired ids are not aliases.
422        assert_eq!(plan("pro"), HivePlan::Unknown);
423        assert_eq!(plan("business"), HivePlan::Unknown);
424        assert_eq!(plan("community"), HivePlan::Unknown);
425        assert_eq!(
426            plan("galaxy"),
427            HivePlan::Unknown,
428            "new plans stay tolerated"
429        );
430        assert_eq!(serde_json::to_value(HivePlan::NoPlan).unwrap(), "none");
431        assert_eq!(
432            serde_json::to_value(HivePlan::Personal).unwrap(),
433            "personal"
434        );
435    }
436
437    #[test]
438    fn lease_moves_from_valid_to_grace_to_lapsed() {
439        let lease = lease();
440        assert_eq!(
441            lease.state_at(at("2026-10-21T11:59:59Z")).unwrap(),
442            LeaseState::Valid
443        );
444        assert_eq!(
445            lease.state_at(at("2026-10-21T12:00:00Z")).unwrap(),
446            LeaseState::Grace
447        );
448        assert_eq!(
449            lease.state_at(at("2026-10-28T12:00:00Z")).unwrap(),
450            LeaseState::Lapsed
451        );
452        assert_eq!(
453            lease.state_at(at("2026-10-20T11:44:59Z")).unwrap(),
454            LeaseState::Lapsed
455        );
456        assert!(lease.renew_due(at("2026-10-21T06:00:01Z")).unwrap());
457        assert!(!lease.renew_due(at("2026-10-21T05:59:59Z")).unwrap());
458    }
459
460    #[test]
461    fn lease_windows_are_bounded() {
462        let mut long = lease();
463        long.expires_at = UtcTimestamp::new("2026-10-21T12:00:01Z").unwrap();
464        assert!(long.validate().is_err());
465        let mut grace = lease();
466        grace.grace_until = UtcTimestamp::new("2026-10-28T12:00:01Z").unwrap();
467        assert!(grace.validate().is_err());
468        assert!(lease().validate().is_ok());
469    }
470
471    #[test]
472    fn signature_covers_unknown_lease_fields() {
473        let mut envelope =
474            HiveLeaseEnvelopeV1::seal(&lease(), "k1", "A".repeat(86) + "==").unwrap();
475        let before = envelope.signing_input().unwrap();
476        envelope.lease["future_field"] = Value::from(1);
477        assert_ne!(before, envelope.signing_input().unwrap());
478        assert!(envelope.lease().is_ok());
479    }
480
481    #[test]
482    fn unknown_rights_grant_nothing() {
483        let mut lease = lease();
484        lease.rights.push(HiveRight::Unknown);
485        assert!(!lease.grants(HiveRight::Unknown));
486        assert!(lease.grants(HiveRight::KnowledgeRead));
487        assert!(!lease.grants(HiveRight::KnowledgeMerge));
488    }
489}