Skip to main content

lean_ctx_protocol/hive/
optin.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Engine opt-in (C0 1.9, section 4; owner decision O1).
4//!
5//! An engine's agents work on tasks only by *pulling* them, and only within
6//! the opt-in its owner set on the web: which vaults and networks, how
7//! much per day, and whether at all. Hive never starts an agent;
8//! nothing here spawns anything.
9//!
10//! `GET /v1/me/optin` (instance token) → [`EngineOptinV1`]. The owner
11//! changes it on the web; every change gets a new `optin_id`, and
12//! `active: false` (the stop) ends every claim of that engine at once.
13//! The engine may stop itself, never start: `PUT /v1/me/optin`
14//! [`EngineOptinStopV1`] → [`EngineOptinV1`]; `active: true` there is
15//! `403 forbidden_right` reason `owner_only`.
16//!
17//! Hive is the source of truth and enforces it on every claim:
18//! - a claim carries the `optin_id` the engine last read (section 1
19//!   `TaskClaimRequestV1.optin_id`, required since this section; without it
20//!   `400 invalid_request` field `optin_id`);
21//! - an older `optin_id` → `409 invalid_state` reason `optin_changed`: the
22//!   engine reads the opt-in again before it pulls;
23//! - `active: false`, or no opt-in → `409 invalid_state` reason
24//!   `optin_inactive`;
25//! - a vault or network outside the opt-in → `403 forbidden_right` reason
26//!   `optin_scope`;
27//! - the day's budget used up → `429 rate_limited` reason `daily_budget`,
28//!   with `limit`, `used` (claims) and `retry_after_s` until 00:00 UTC.
29//!
30//! Usage counts per UTC day: a claim counts one task when it is granted; the
31//! `usage` of task results (section 1) adds tokens and `usd_micros`. A claim
32//! is refused once any set limit is reached; a running claim is never cut
33//! short by the budget. A granted claim counts atomically: parallel claims
34//! of one engine never exceed `daily_budget.tasks` together.
35//!
36//! An engine may apply stricter local caps (fewer tasks, a lower budget, a
37//! narrower scope). It never widens what Hive allows.
38
39use serde::{Deserialize, Serialize};
40
41use super::{require_schema, validate_id};
42use crate::{UtcTimestamp, ValidationError};
43
44/// A network an engine may pull questions from: an org channel
45/// (`org:<org_id>/vault:<vault_id>/<name>`). Any other kind does not decode.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
48pub enum NetworkV1 {
49    Org { channel: String },
50}
51
52impl NetworkV1 {
53    pub fn validate(&self) -> Result<(), ValidationError> {
54        match self {
55            Self::Org { channel } => validate_channel(channel),
56        }
57    }
58}
59
60/// Validate `org:<org>/vault:<vault_id>/<name>`.
61fn validate_channel(channel: &str) -> Result<(), ValidationError> {
62    let mut parts = channel.splitn(3, '/');
63    let org = parts.next().and_then(|part| part.strip_prefix("org:"));
64    let vault = parts.next().and_then(|part| part.strip_prefix("vault:"));
65    let name = parts.next();
66    match (org, vault, name) {
67        (Some(org), Some(vault), Some(name)) => {
68            validate_id(org, "channel org")?;
69            super::validate_vault_id(vault)?;
70            validate_id(name, "channel name")
71        }
72        _ => Err(ValidationError::new(
73            "channel must be org:<org>/vault:<vault>/<name>",
74        )),
75    }
76}
77
78pub const ENGINE_OPTIN_SCHEMA: &str = "leanctx.hive.instance-optin/v1";
79pub const ENGINE_OPTIN_STOP_SCHEMA: &str = "leanctx.hive.instance-optin-stop/v1";
80pub const DEFAULT_DAILY_TASKS: u32 = 5;
81pub const MAX_DAILY_TASKS: u32 = 100;
82pub const MAX_OPTIN_VAULTS: usize = 200;
83pub const MAX_OPTIN_NETWORKS: usize = 200;
84
85/// Limits per UTC day. A limit that is absent is no limit; since Hive
86/// task #19 (owner decision 23.10.) that holds for the task count too.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub struct DailyBudgetV1 {
89    /// Claims per day, 1-100 (default 5); `None`: no limit.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub tasks: Option<u32>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub tokens: Option<u64>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub usd_micros: Option<u64>,
96}
97
98impl Default for DailyBudgetV1 {
99    fn default() -> Self {
100        Self {
101            tasks: Some(DEFAULT_DAILY_TASKS),
102            tokens: None,
103            usd_micros: None,
104        }
105    }
106}
107
108/// What the engine used today (UTC).
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct DailyUsageV1 {
111    /// `YYYY-MM-DD`, UTC.
112    pub day: String,
113    pub tasks: u32,
114    pub tokens: u64,
115    pub usd_micros: u64,
116}
117
118/// `GET /v1/me/optin`.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct EngineOptinV1 {
121    pub schema: String,
122    /// Absent when the owner never opted this engine in (then `active` is
123    /// false and every list is empty).
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub optin_id: Option<String>,
126    pub active: bool,
127    /// Vaults (`vault_id`) whose tasks the agents may pull.
128    #[serde(default)]
129    pub vaults: Vec<String>,
130    /// Networks whose questions they may pull ([`NetworkV1`]).
131    #[serde(default)]
132    pub networks: Vec<NetworkV1>,
133    pub daily_budget: DailyBudgetV1,
134    pub used_today: DailyUsageV1,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub stopped_at: Option<UtcTimestamp>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub updated_at: Option<UtcTimestamp>,
139}
140
141fn day_ok(day: &str) -> bool {
142    let b = day.as_bytes();
143    b.len() == 10
144        && b[4] == b'-'
145        && b[7] == b'-'
146        && [0..4, 5..7, 8..10]
147            .iter()
148            .all(|r| b[r.clone()].iter().all(u8::is_ascii_digit))
149}
150
151impl EngineOptinV1 {
152    pub fn validate(&self) -> Result<(), ValidationError> {
153        require_schema(&self.schema, ENGINE_OPTIN_SCHEMA)?;
154        match &self.optin_id {
155            Some(id) => validate_id(id, "optin_id")?,
156            None if self.active || !self.vaults.is_empty() || !self.networks.is_empty() => {
157                return Err(ValidationError::new(
158                    "an engine without opt-in is inactive with empty scope",
159                ));
160            }
161            None => {}
162        }
163        if self.vaults.len() > MAX_OPTIN_VAULTS || self.networks.len() > MAX_OPTIN_NETWORKS {
164            return Err(ValidationError::new(format!(
165                "at most {MAX_OPTIN_VAULTS} vaults and {MAX_OPTIN_NETWORKS} networks"
166            )));
167        }
168        for (i, vault) in self.vaults.iter().enumerate() {
169            super::validate_vault_id(vault)?;
170            if i > 0 && self.vaults[i - 1] >= *vault {
171                return Err(ValidationError::new(
172                    "vaults must be sorted without repeats",
173                ));
174            }
175        }
176        if self
177            .daily_budget
178            .tasks
179            .is_some_and(|t| !(1..=MAX_DAILY_TASKS).contains(&t))
180            || self.daily_budget.tokens == Some(0)
181        {
182            return Err(ValidationError::new(
183                "daily_budget is 1-100 tasks or none, and a non-zero token limit",
184            ));
185        }
186        if !day_ok(&self.used_today.day) {
187            return Err(ValidationError::new("used_today.day is YYYY-MM-DD"));
188        }
189        if self.active == self.stopped_at.is_some() && self.optin_id.is_some() {
190            return Err(ValidationError::new(
191                "stopped_at is set exactly while the opt-in is inactive",
192            ));
193        }
194        Ok(())
195    }
196}
197
198/// `PUT /v1/me/optin`: the engine's own stop.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct EngineOptinStopV1 {
202    pub schema: String,
203    /// Always `false`; only the owner turns an opt-in on.
204    pub active: bool,
205}
206
207impl EngineOptinStopV1 {
208    pub fn validate(&self) -> Result<(), ValidationError> {
209        require_schema(&self.schema, ENGINE_OPTIN_STOP_SCHEMA)?;
210        if self.active {
211            return Err(ValidationError::new("an engine can only stop its opt-in"));
212        }
213        Ok(())
214    }
215}