lean_ctx_protocol/hive/
optin.rs1use serde::{Deserialize, Serialize};
40
41use super::{require_schema, validate_id};
42use crate::{UtcTimestamp, ValidationError};
43
44#[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
60fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub struct DailyBudgetV1 {
89 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct DailyUsageV1 {
111 pub day: String,
113 pub tasks: u32,
114 pub tokens: u64,
115 pub usd_micros: u64,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct EngineOptinV1 {
121 pub schema: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub optin_id: Option<String>,
126 pub active: bool,
127 #[serde(default)]
129 pub vaults: Vec<String>,
130 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct EngineOptinStopV1 {
202 pub schema: String,
203 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}