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//! - Hive-Protocol 2.1: an enrollment of a 2.1+ engine carries key set v3
10//!   ([`HiveKeySetV3`], C0 2.x `keyset.schema.json`) instead of v1; the
11//!   lease `kid` then names the v3 key of purpose `policy`
12//!   ([`EnrollmentKeySet`]).
13//!
14//! A lease is `Valid` until `expires_at`, `Grace` until `grace_until`, then
15//! `Lapsed`. Local tools never depend on the lease; only Hive traffic does.
16
17use chrono::{DateTime, Duration, FixedOffset};
18use serde::{Deserialize, Deserializer, Serialize};
19use serde_json::Value;
20
21use super::{
22    KEYSET_SIGNATURE_DOMAIN, LEASE_SIGNATURE_DOMAIN, parse_time, require_schema, signing_input,
23    signing_input_without_signature, validate_b64_len, validate_id,
24};
25use crate::{UtcTimestamp, ValidationError};
26
27pub const LEASE_SCHEMA: &str = "leanctx.hive.lease/v1";
28pub const LEASE_ENVELOPE_SCHEMA: &str = "leanctx.hive.lease-envelope/v1";
29pub const KEYSET_SCHEMA: &str = "leanctx.hive.keyset/v1";
30/// Key set v3 (C0 2.x E13), in enrollments of Hive-Protocol 2.1+.
31pub const KEYSET_V3_SCHEMA: &str = "leanctx.hive.keyset/v3";
32/// The v3 purpose of the keys that sign leases (and policy bundles).
33pub const LEASE_KEY_PURPOSE: &str = "policy";
34
35/// Longest `expires_at - issued_at`.
36pub const MAX_LEASE_VALIDITY_HOURS: i64 = 24;
37/// Longest `grace_until - expires_at`.
38pub const MAX_LEASE_GRACE_DAYS: i64 = 7;
39/// A lease issued further than this in the future (after offset correction) is refused.
40pub const MAX_LEASE_FUTURE_SKEW_MINUTES: i64 = 15;
41/// The engine renews when less than this remains before `expires_at`.
42pub const LEASE_RENEW_BEFORE_HOURS: i64 = 6;
43
44/// Plan of the organization.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum HivePlan {
48    /// A workspace without a paid plan: Hive sends `none`.
49    #[serde(rename = "none")]
50    NoPlan,
51    Personal,
52    Team,
53    Enterprise,
54    #[serde(other)]
55    Unknown,
56}
57
58impl HivePlan {
59    /// The plan's name as people read it: Personal, Team and Enterprise.
60    pub fn display_name(self) -> &'static str {
61        match self {
62            Self::NoPlan => "No plan",
63            Self::Personal => "Personal",
64            Self::Team => "Team",
65            Self::Enterprise => "Enterprise",
66            Self::Unknown => "unknown",
67        }
68    }
69
70    /// The plan's product name: Personal, Team, Enterprise.
71    pub fn product_name(self) -> &'static str {
72        match self {
73            Self::NoPlan => "No plan",
74            Self::Personal => "Personal",
75            Self::Team => "Team",
76            Self::Enterprise => "Enterprise",
77            Self::Unknown => "Unknown",
78        }
79    }
80}
81
82/// A right granted to this instance. Unknown rights decode as `Unknown` and grant nothing.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
84pub enum HiveRight {
85    #[serde(rename = "knowledge.read")]
86    KnowledgeRead,
87    #[serde(rename = "knowledge.draft")]
88    KnowledgeDraft,
89    #[serde(rename = "knowledge.merge")]
90    KnowledgeMerge,
91    #[serde(rename = "metrics.upload")]
92    MetricsUpload,
93    #[serde(rename = "receipts.upload")]
94    ReceiptsUpload,
95    #[serde(rename = "policy.pull")]
96    PolicyPull,
97    #[serde(rename = "bus.relay")]
98    BusRelay,
99    #[serde(rename = "bus.control")]
100    BusControl,
101    /// Decoded and grants nothing: an engine does not use sealed vaults.
102    #[serde(rename = "sealed")]
103    Sealed,
104    /// 1.2: the Intelligence runtime may run for this instance (what it
105    /// enables stays gated by `plan`).
106    #[serde(rename = "runtime")]
107    Runtime,
108    /// 1.9: read the vault's tasks.
109    #[serde(rename = "tasks.read")]
110    TasksRead,
111    /// 1.9: claim tasks, report updates and submit results.
112    #[serde(rename = "tasks.work")]
113    TasksWork,
114    /// 1.9: propose tasks (a person approves them).
115    #[serde(rename = "tasks.propose")]
116    TasksPropose,
117    /// mz6: the subscription models (`leanctx/<model>`) may be served to
118    /// this instance through the Hive models gateway; plan and credit stay
119    /// the gateway's check on every request.
120    #[serde(rename = "models.subscription")]
121    ModelsSubscription,
122    #[serde(other)]
123    Unknown,
124}
125
126/// The signed lease statement.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct HiveLeaseV1 {
129    pub schema: String,
130    pub lease_id: String,
131    pub org_id: String,
132    pub instance_id: String,
133    pub plan: HivePlan,
134    /// The organization's limit of engine instances (Personal 2,
135    /// Team 2 per seat, Enterprise 3 per seat and at least 25); the wire
136    /// name is frozen in v1.
137    pub seats_limit: u32,
138    pub rights: Vec<HiveRight>,
139    pub issued_at: UtcTimestamp,
140    pub expires_at: UtcTimestamp,
141    pub grace_until: UtcTimestamp,
142    /// Service clock at signing; the engine keeps `server_time - local_now` as offset.
143    pub server_time: UtcTimestamp,
144    /// 1.4: what the organization allows its engines to upload. Absent
145    /// (an older service) allows both.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub org_settings: Option<HiveOrgSettingsV1>,
148}
149
150/// Organization-wide switches the lease carries (1.4). They only narrow:
151/// an engine uploads only what both its own `[hive]` setting and these
152/// allow, and receipts still need the `receipts.upload` right.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154pub struct HiveOrgSettingsV1 {
155    #[serde(default = "allowed")]
156    pub metrics_upload: bool,
157    #[serde(default = "allowed")]
158    pub receipts_upload: bool,
159}
160
161const fn allowed() -> bool {
162    true
163}
164
165/// Where a lease stands at a given (offset-corrected) time.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum LeaseState {
169    Valid,
170    Grace,
171    Lapsed,
172}
173
174impl HiveLeaseV1 {
175    pub fn validate(&self) -> Result<(), ValidationError> {
176        require_schema(&self.schema, LEASE_SCHEMA)?;
177        validate_id(&self.lease_id, "lease_id")?;
178        validate_id(&self.org_id, "org_id")?;
179        validate_id(&self.instance_id, "instance_id")?;
180        if self.seats_limit == 0 {
181            return Err(ValidationError::new("seats_limit must be positive"));
182        }
183        if self.rights.len() > 64 {
184            return Err(ValidationError::new("lease lists are too long"));
185        }
186        let issued = parse_time(&self.issued_at)?;
187        let expires = parse_time(&self.expires_at)?;
188        let grace = parse_time(&self.grace_until)?;
189        parse_time(&self.server_time)?;
190        if expires <= issued || expires - issued > Duration::hours(MAX_LEASE_VALIDITY_HOURS) {
191            return Err(ValidationError::new(
192                "expires_at must be after issued_at and at most 24 h later",
193            ));
194        }
195        if grace < expires || grace - expires > Duration::days(MAX_LEASE_GRACE_DAYS) {
196            return Err(ValidationError::new(
197                "grace_until must be within 7 days after expires_at",
198            ));
199        }
200        Ok(())
201    }
202
203    /// Whether the lease grants `right`.
204    pub fn grants(&self, right: HiveRight) -> bool {
205        right != HiveRight::Unknown && self.rights.contains(&right)
206    }
207
208    /// Whether the organization allows metrics uploads (absent settings allow).
209    pub fn org_allows_metrics_upload(&self) -> bool {
210        self.org_settings
211            .is_none_or(|settings| settings.metrics_upload)
212    }
213
214    /// Whether the organization allows receipt uploads (absent settings allow).
215    pub fn org_allows_receipts_upload(&self) -> bool {
216        self.org_settings
217            .is_none_or(|settings| settings.receipts_upload)
218    }
219
220    /// State at `now` (service time: local clock plus the stored offset).
221    /// A lease issued more than 15 minutes after `now` counts as `Lapsed`.
222    pub fn state_at(&self, now: DateTime<FixedOffset>) -> Result<LeaseState, ValidationError> {
223        let issued = parse_time(&self.issued_at)?;
224        if issued - now > Duration::minutes(MAX_LEASE_FUTURE_SKEW_MINUTES) {
225            return Ok(LeaseState::Lapsed);
226        }
227        Ok(if now < parse_time(&self.expires_at)? {
228            LeaseState::Valid
229        } else if now < parse_time(&self.grace_until)? {
230            LeaseState::Grace
231        } else {
232            LeaseState::Lapsed
233        })
234    }
235
236    /// Whether the engine should renew now.
237    pub fn renew_due(&self, now: DateTime<FixedOffset>) -> Result<bool, ValidationError> {
238        Ok(parse_time(&self.expires_at)? - now < Duration::hours(LEASE_RENEW_BEFORE_HOURS))
239    }
240}
241
242/// Lease as it travels: the raw lease object plus its signature.
243///
244/// `lease` stays raw JSON so the signature covers exactly what the service
245/// signed, including fields this engine does not know.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct HiveLeaseEnvelopeV1 {
248    pub schema: String,
249    pub lease: Value,
250    /// Key id within [`HiveKeySetV1`].
251    pub kid: String,
252    /// Ed25519 signature (base64) over [`HiveLeaseEnvelopeV1::signing_input`].
253    pub signature: String,
254}
255
256impl HiveLeaseEnvelopeV1 {
257    /// Envelope fields only; call [`Self::lease`] after verifying the signature.
258    pub fn validate_shape(&self) -> Result<(), ValidationError> {
259        require_schema(&self.schema, LEASE_ENVELOPE_SCHEMA)?;
260        validate_id(&self.kid, "kid")?;
261        validate_b64_len(&self.signature, 64, "signature")?;
262        if !self.lease.is_object() {
263            return Err(ValidationError::new("lease must be a JSON object"));
264        }
265        Ok(())
266    }
267
268    /// `"leanctx.hive.lease.v1\n" || canonical_json(lease)`.
269    pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
270        signing_input(LEASE_SIGNATURE_DOMAIN, &self.lease)
271    }
272
273    /// Signing input for a lease the service is about to sign.
274    pub fn signing_input_for(lease: &HiveLeaseV1) -> Result<Vec<u8>, ValidationError> {
275        lease.validate()?;
276        signing_input(LEASE_SIGNATURE_DOMAIN, &lease_value(lease)?)
277    }
278
279    /// Build the envelope for a signed lease.
280    pub fn seal(
281        lease: &HiveLeaseV1,
282        kid: impl Into<String>,
283        signature: impl Into<String>,
284    ) -> Result<Self, ValidationError> {
285        lease.validate()?;
286        let envelope = Self {
287            schema: LEASE_ENVELOPE_SCHEMA.into(),
288            lease: lease_value(lease)?,
289            kid: kid.into(),
290            signature: signature.into(),
291        };
292        envelope.validate_shape()?;
293        Ok(envelope)
294    }
295
296    /// The typed, validated lease.
297    pub fn lease(&self) -> Result<HiveLeaseV1, ValidationError> {
298        let lease: HiveLeaseV1 = serde_json::from_value(self.lease.clone())
299            .map_err(|error| ValidationError::new(format!("lease: {error}")))?;
300        lease.validate()?;
301        Ok(lease)
302    }
303}
304
305fn lease_value(lease: &HiveLeaseV1) -> Result<Value, ValidationError> {
306    serde_json::to_value(lease).map_err(|error| ValidationError::new(format!("lease: {error}")))
307}
308
309/// One Hive signing key.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct HiveKeyV1 {
312    pub kid: String,
313    /// Ed25519 public key, 32 bytes base64.
314    pub ed25519_pub: String,
315    /// The key signs nothing after this time; signatures made before stay valid
316    /// until the signed object itself expires.
317    pub not_after: UtcTimestamp,
318}
319
320/// `GET /v1/keys`. The signature covers the object without `signature`.
321/// Engines keep the raw JSON (`keys.json`) and verify it with
322/// [`HiveKeySetV1::signing_input_raw`].
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct HiveKeySetV1 {
325    pub schema: String,
326    pub keys: Vec<HiveKeyV1>,
327    /// Ed25519 signature (base64) of the Hive root key.
328    pub signature: String,
329}
330
331impl HiveKeySetV1 {
332    pub fn validate(&self) -> Result<(), ValidationError> {
333        require_schema(&self.schema, KEYSET_SCHEMA)?;
334        if self.keys.is_empty() || self.keys.len() > 16 {
335            return Err(ValidationError::new("keys must list 1-16 keys"));
336        }
337        let mut seen = std::collections::BTreeSet::new();
338        for key in &self.keys {
339            validate_id(&key.kid, "kid")?;
340            validate_b64_len(&key.ed25519_pub, 32, "ed25519_pub")?;
341            parse_time(&key.not_after)?;
342            if !seen.insert(key.kid.as_str()) {
343                return Err(ValidationError::new("kid repeats in key set"));
344            }
345        }
346        validate_b64_len(&self.signature, 64, "signature")
347    }
348
349    /// Signing input of a key set as received.
350    pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
351        signing_input_without_signature(KEYSET_SIGNATURE_DOMAIN, raw)
352    }
353
354    /// Signing input of a key set the service is about to sign (`signature` ignored).
355    pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
356        let value = serde_json::to_value(self)
357            .map_err(|error| ValidationError::new(format!("keyset: {error}")))?;
358        Self::signing_input_raw(&value)
359    }
360
361    /// Key `kid`, if listed.
362    pub fn key(&self, kid: &str) -> Option<&HiveKeyV1> {
363        self.keys.iter().find(|key| key.kid == kid)
364    }
365}
366
367/// One key of a key set v3. Purposes stay strings here: the set's root
368/// signature covers them, and the engine checks each use against them.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct HiveKeyV3 {
371    /// `hex(blake3(public key))[..16]`.
372    pub key_id: String,
373    /// base64url (no padding) of the raw 32-byte Ed25519 key.
374    pub public_key: String,
375    pub purposes: Vec<String>,
376    /// Unix milliseconds.
377    pub created_at: u64,
378    /// Unix milliseconds; `None` while the key is in use.
379    pub revoked_at: Option<u64>,
380}
381
382/// One root signature of a key set v3.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct HiveRootSignatureV3 {
385    pub key_id: String,
386    /// hex Ed25519 over `"leanctx.hive.keyset.v3\n" ‖ canonical_json({schema, keys})`.
387    pub signature: String,
388}
389
390/// Key set v3 as an enrollment of Hive-Protocol 2.1+ carries it. The
391/// engine verifies `signatures` against its trusted roots; the legacy
392/// single `signature` is for pre-3.12 engines.
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394pub struct HiveKeySetV3 {
395    pub schema: String,
396    pub keys: Vec<HiveKeyV3>,
397    pub signatures: Vec<HiveRootSignatureV3>,
398    pub signature: String,
399}
400
401fn lower_hex(value: &str, len: usize) -> bool {
402    value.len() == len
403        && value
404            .bytes()
405            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
406}
407
408impl HiveKeySetV3 {
409    pub fn validate(&self) -> Result<(), ValidationError> {
410        require_schema(&self.schema, KEYSET_V3_SCHEMA)?;
411        if self.keys.is_empty() || self.keys.len() > 64 {
412            return Err(ValidationError::new("keys must list 1-64 keys"));
413        }
414        let mut seen = std::collections::BTreeSet::new();
415        for key in &self.keys {
416            if !lower_hex(&key.key_id, 16) || !seen.insert(key.key_id.as_str()) {
417                return Err(ValidationError::new(
418                    "key_id must be 16 lowercase hex characters, once per set",
419                ));
420            }
421            if key.public_key.len() != 43
422                || !key
423                    .public_key
424                    .bytes()
425                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
426            {
427                return Err(ValidationError::new(
428                    "public_key must be base64url of 32 bytes",
429                ));
430            }
431            let mut purposes = std::collections::BTreeSet::new();
432            if key.purposes.is_empty() || !key.purposes.iter().all(|p| purposes.insert(p)) {
433                return Err(ValidationError::new("purposes must list each purpose once"));
434            }
435            if key.revoked_at.is_some_and(|at| at <= key.created_at) {
436                return Err(ValidationError::new("revoked_at must follow created_at"));
437            }
438        }
439        if self.signatures.is_empty() || self.signatures.len() > 2 {
440            return Err(ValidationError::new(
441                "signatures must list 1-2 root signatures",
442            ));
443        }
444        if !self
445            .signatures
446            .iter()
447            .all(|s| lower_hex(&s.key_id, 16) && lower_hex(&s.signature, 128))
448        {
449            return Err(ValidationError::new(
450                "a root signature is a 16-hex key_id and a 128-hex signature",
451            ));
452        }
453        validate_b64_len(&self.signature, 64, "signature")
454    }
455
456    /// The key that may sign a lease named `kid`: listed with purpose
457    /// [`LEASE_KEY_PURPOSE`].
458    pub fn lease_key(&self, kid: &str) -> Option<&HiveKeyV3> {
459        self.keys
460            .iter()
461            .find(|key| key.key_id == kid && key.purposes.iter().any(|p| p == LEASE_KEY_PURPOSE))
462    }
463}
464
465/// The key set of an enrollment: v1 for an engine below Hive-Protocol 2.1,
466/// v3 from 2.1 (told apart by `schema`). Either way the lease `kid` names a
467/// key of this set, and the engine verifies the set as it verifies that
468/// version everywhere else.
469#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
470#[serde(untagged)]
471pub enum EnrollmentKeySet {
472    V1(HiveKeySetV1),
473    V3(HiveKeySetV3),
474}
475
476impl<'de> Deserialize<'de> for EnrollmentKeySet {
477    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
478        use serde::de::Error as _;
479        let raw = Value::deserialize(deserializer)?;
480        if raw["schema"] == KEYSET_V3_SCHEMA {
481            serde_json::from_value(raw)
482                .map(Self::V3)
483                .map_err(D::Error::custom)
484        } else {
485            serde_json::from_value(raw)
486                .map(Self::V1)
487                .map_err(D::Error::custom)
488        }
489    }
490}
491
492impl EnrollmentKeySet {
493    pub fn validate(&self) -> Result<(), ValidationError> {
494        match self {
495            Self::V1(set) => set.validate(),
496            Self::V3(set) => set.validate(),
497        }
498    }
499
500    /// Whether the set lists a key that may sign a lease named `kid`.
501    pub fn signs_leases_as(&self, kid: &str) -> bool {
502        match self {
503            Self::V1(set) => set.key(kid).is_some(),
504            Self::V3(set) => set.lease_key(kid).is_some(),
505        }
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn product_names_are_personal_team_enterprise() {
515        let names: Vec<&str> = [
516            HivePlan::NoPlan,
517            HivePlan::Personal,
518            HivePlan::Team,
519            HivePlan::Enterprise,
520        ]
521        .into_iter()
522        .map(HivePlan::product_name)
523        .collect();
524        assert_eq!(names, ["No plan", "Personal", "Team", "Enterprise"]);
525        // the wire name stays
526        assert_eq!(
527            serde_json::to_value(HivePlan::Enterprise).unwrap(),
528            serde_json::json!("enterprise")
529        );
530    }
531
532    fn lease() -> HiveLeaseV1 {
533        HiveLeaseV1 {
534            schema: LEASE_SCHEMA.into(),
535            lease_id: "lse_1".into(),
536            org_id: "org_1".into(),
537            instance_id: "inst_1".into(),
538            plan: HivePlan::Team,
539            seats_limit: 25,
540            rights: vec![HiveRight::KnowledgeRead],
541            issued_at: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
542            expires_at: UtcTimestamp::new("2026-10-21T12:00:00Z").unwrap(),
543            grace_until: UtcTimestamp::new("2026-10-28T12:00:00Z").unwrap(),
544            server_time: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
545            org_settings: None,
546        }
547    }
548
549    fn at(value: &str) -> DateTime<FixedOffset> {
550        DateTime::parse_from_rfc3339(value).unwrap()
551    }
552
553    /// Dogfood 25.10.: a personal workspace (plan `none`) showed "unknown
554    /// plan" at connect.
555    #[test]
556    fn plan_wire_ids_are_none_personal_team_enterprise() {
557        let plan = |s: &str| serde_json::from_value::<HivePlan>(serde_json::json!(s)).unwrap();
558        assert_eq!(plan("none"), HivePlan::NoPlan);
559        assert_eq!(plan("personal"), HivePlan::Personal);
560        assert_eq!(plan("none").display_name(), "No plan");
561        assert_eq!(plan("team").display_name(), "Team");
562        assert_eq!(plan("enterprise").display_name(), "Enterprise");
563        // Other ids are not aliases.
564        assert_eq!(plan("pro"), HivePlan::Unknown);
565        assert_eq!(plan("business"), HivePlan::Unknown);
566        assert_eq!(plan("community"), HivePlan::Unknown);
567        assert_eq!(
568            plan("galaxy"),
569            HivePlan::Unknown,
570            "new plans stay tolerated"
571        );
572        assert_eq!(serde_json::to_value(HivePlan::NoPlan).unwrap(), "none");
573        assert_eq!(
574            serde_json::to_value(HivePlan::Personal).unwrap(),
575            "personal"
576        );
577    }
578
579    #[test]
580    fn lease_moves_from_valid_to_grace_to_lapsed() {
581        let lease = lease();
582        assert_eq!(
583            lease.state_at(at("2026-10-21T11:59:59Z")).unwrap(),
584            LeaseState::Valid
585        );
586        assert_eq!(
587            lease.state_at(at("2026-10-21T12:00:00Z")).unwrap(),
588            LeaseState::Grace
589        );
590        assert_eq!(
591            lease.state_at(at("2026-10-28T12:00:00Z")).unwrap(),
592            LeaseState::Lapsed
593        );
594        assert_eq!(
595            lease.state_at(at("2026-10-20T11:44:59Z")).unwrap(),
596            LeaseState::Lapsed
597        );
598        assert!(lease.renew_due(at("2026-10-21T06:00:01Z")).unwrap());
599        assert!(!lease.renew_due(at("2026-10-21T05:59:59Z")).unwrap());
600    }
601
602    #[test]
603    fn lease_windows_are_bounded() {
604        let mut long = lease();
605        long.expires_at = UtcTimestamp::new("2026-10-21T12:00:01Z").unwrap();
606        assert!(long.validate().is_err());
607        let mut grace = lease();
608        grace.grace_until = UtcTimestamp::new("2026-10-28T12:00:01Z").unwrap();
609        assert!(grace.validate().is_err());
610        assert!(lease().validate().is_ok());
611    }
612
613    #[test]
614    fn signature_covers_unknown_lease_fields() {
615        let mut envelope =
616            HiveLeaseEnvelopeV1::seal(&lease(), "k1", "A".repeat(86) + "==").unwrap();
617        let before = envelope.signing_input().unwrap();
618        envelope.lease["future_field"] = Value::from(1);
619        assert_ne!(before, envelope.signing_input().unwrap());
620        assert!(envelope.lease().is_ok());
621    }
622
623    #[test]
624    fn unknown_rights_grant_nothing() {
625        let mut lease = lease();
626        lease.rights.push(HiveRight::Unknown);
627        assert!(!lease.grants(HiveRight::Unknown));
628        assert!(lease.grants(HiveRight::KnowledgeRead));
629        assert!(!lease.grants(HiveRight::KnowledgeMerge));
630    }
631}