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, which
7//! topics, how 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::messages::NetworkV1;
42use super::{require_schema, validate_id};
43use crate::{UtcTimestamp, ValidationError};
44
45pub const ENGINE_OPTIN_SCHEMA: &str = "leanctx.hive.engine-optin/v1";
46pub const ENGINE_OPTIN_STOP_SCHEMA: &str = "leanctx.hive.engine-optin-stop/v1";
47pub const DEFAULT_DAILY_TASKS: u32 = 5;
48pub const MAX_DAILY_TASKS: u32 = 100;
49pub const MAX_OPTIN_VAULTS: usize = 200;
50pub const MAX_OPTIN_NETWORKS: usize = 200;
51pub const MAX_OPTIN_TOPICS: usize = 5;
52
53/// Limits per UTC day. A limit that is absent is no limit; since Hive
54/// task #19 (owner decision 23.10.) that holds for the task count too.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub struct DailyBudgetV1 {
57    /// Claims per day, 1-100 (default 5); `None`: no limit.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub tasks: Option<u32>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub tokens: Option<u64>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub usd_micros: Option<u64>,
64}
65
66impl Default for DailyBudgetV1 {
67    fn default() -> Self {
68        Self {
69            tasks: Some(DEFAULT_DAILY_TASKS),
70            tokens: None,
71            usd_micros: None,
72        }
73    }
74}
75
76/// What the engine used today (UTC).
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct DailyUsageV1 {
79    /// `YYYY-MM-DD`, UTC.
80    pub day: String,
81    pub tasks: u32,
82    pub tokens: u64,
83    pub usd_micros: u64,
84}
85
86/// `GET /v1/me/optin`.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct EngineOptinV1 {
89    pub schema: String,
90    /// Absent when the owner never opted this engine in (then `active` is
91    /// false and every list is empty).
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub optin_id: Option<String>,
94    pub active: bool,
95    /// Vaults (`vault_id`) whose tasks the agents may pull.
96    #[serde(default)]
97    pub vaults: Vec<String>,
98    /// Networks whose questions and swarm subtasks they may pull
99    /// (section 2 `NetworkV1`).
100    #[serde(default)]
101    pub networks: Vec<NetworkV1>,
102    /// Swarm topics (2-32 of `a-z`, `0-9`, `-`).
103    #[serde(default)]
104    pub topics: Vec<String>,
105    pub daily_budget: DailyBudgetV1,
106    pub used_today: DailyUsageV1,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub stopped_at: Option<UtcTimestamp>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub updated_at: Option<UtcTimestamp>,
111}
112
113fn topic_ok(topic: &str) -> bool {
114    (2..=32).contains(&topic.len())
115        && topic
116            .bytes()
117            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
118}
119
120fn day_ok(day: &str) -> bool {
121    let b = day.as_bytes();
122    b.len() == 10
123        && b[4] == b'-'
124        && b[7] == b'-'
125        && [0..4, 5..7, 8..10]
126            .iter()
127            .all(|r| b[r.clone()].iter().all(u8::is_ascii_digit))
128}
129
130impl EngineOptinV1 {
131    pub fn validate(&self) -> Result<(), ValidationError> {
132        require_schema(&self.schema, ENGINE_OPTIN_SCHEMA)?;
133        match &self.optin_id {
134            Some(id) => validate_id(id, "optin_id")?,
135            None if self.active
136                || !self.vaults.is_empty()
137                || !self.networks.is_empty()
138                || !self.topics.is_empty() =>
139            {
140                return Err(ValidationError::new(
141                    "an engine without opt-in is inactive with empty scope",
142                ));
143            }
144            None => {}
145        }
146        if self.vaults.len() > MAX_OPTIN_VAULTS || self.networks.len() > MAX_OPTIN_NETWORKS {
147            return Err(ValidationError::new(format!(
148                "at most {MAX_OPTIN_VAULTS} vaults and {MAX_OPTIN_NETWORKS} networks"
149            )));
150        }
151        for (i, vault) in self.vaults.iter().enumerate() {
152            super::validate_vault_id(vault)?;
153            if i > 0 && self.vaults[i - 1] >= *vault {
154                return Err(ValidationError::new(
155                    "vaults must be sorted without repeats",
156                ));
157            }
158        }
159        if self.topics.len() > MAX_OPTIN_TOPICS
160            || !self.topics.iter().all(|t| topic_ok(t))
161            || self.topics.windows(2).any(|pair| pair[0] >= pair[1])
162        {
163            return Err(ValidationError::new(
164                "up to 5 topics of 2-32 [a-z0-9-], sorted without repeats",
165            ));
166        }
167        if self
168            .daily_budget
169            .tasks
170            .is_some_and(|t| !(1..=MAX_DAILY_TASKS).contains(&t))
171            || self.daily_budget.tokens == Some(0)
172        {
173            return Err(ValidationError::new(
174                "daily_budget is 1-100 tasks or none, and a non-zero token limit",
175            ));
176        }
177        if !day_ok(&self.used_today.day) {
178            return Err(ValidationError::new("used_today.day is YYYY-MM-DD"));
179        }
180        if self.active == self.stopped_at.is_some() && self.optin_id.is_some() {
181            return Err(ValidationError::new(
182                "stopped_at is set exactly while the opt-in is inactive",
183            ));
184        }
185        Ok(())
186    }
187}
188
189/// `PUT /v1/me/optin`: the engine's own stop.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct EngineOptinStopV1 {
193    pub schema: String,
194    /// Always `false`; only the owner turns an opt-in on.
195    pub active: bool,
196}
197
198impl EngineOptinStopV1 {
199    pub fn validate(&self) -> Result<(), ValidationError> {
200        require_schema(&self.schema, ENGINE_OPTIN_STOP_SCHEMA)?;
201        if self.active {
202            return Err(ValidationError::new("an engine can only stop its opt-in"));
203        }
204        Ok(())
205    }
206}