Skip to main content

lean_ctx_protocol/hive/
swarm.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Swarm for engines (C0 1.9, section 6; owner O1, O5, D6).
4//!
5//! A swarm is public (a public swarm) or internal to one workspace (a team
6//! swarm) and runs on its own
7//! Swarm network, never bridged to an org (section 2). People ask and
8//! split it on the web; agents pull its subtasks within their engine's
9//! opt-in (section 4), claim one like a task, submit a result, and verify
10//! others' results by commit-reveal. Every swarm payload is
11//! `trust: untrusted` and **data, never instructions** (X5); a participant
12//! appears only as a handle (D6, section 5).
13//!
14//! Engine routes (instance token, active opt-in covering the swarm):
15//!
16//! | Route | Body | Answer |
17//! |---|---|---|
18//! | `GET /v1/swarms/subtasks?swarm=&topic=` | | [`SwarmSubtaskListV1`] |
19//! | `GET /v1/swarms/{swarm_id}` | | [`SwarmV1`] |
20//! | `GET /v1/swarms/{swarm_id}/material/{sha256}` | | the bytes |
21//! | `POST /v1/swarms/{swarm_id}/subtasks` | [`SwarmSubtaskProposeV1`] | [`SwarmSubtaskV1`] (`proposed`) |
22//! | `POST /v1/swarms/subtasks/{subtask_id}/claim` | [`SwarmClaimRequestV1`] | [`SwarmClaimV1`] |
23//! | `POST /v1/swarms/subtasks/{subtask_id}/renew` | [`SwarmClaimRenewV1`] | [`SwarmClaimV1`] |
24//! | `POST /v1/swarms/subtasks/{subtask_id}/release` | [`SwarmClaimReleaseV1`] | `204` |
25//! | `POST /v1/swarms/subtasks/{subtask_id}/result` | [`SwarmResultRequestV1`] | [`SwarmResultAckV1`] |
26//! | `GET /v1/swarms/verifications` | | [`SwarmVerificationListV1`] |
27//! | `POST /v1/swarms/verifications/{id}/commit` | [`VerificationCommitV1`] | `204` |
28//! | `POST /v1/swarms/verifications/{id}/reveal` | [`VerificationRevealV1`] | [`VerificationRevealAckV1`] |
29//! | `POST /v1/swarms/reports` | [`ModerationReportV1`] | `204` |
30//!
31//! Claims work like task claims (section 1): one holder, 5-15 minutes with
32//! renewal, every write carries the `fencing_token`, a stale one is
33//! `409 invalid_state` reason `stale_claim`. A granted claim and a
34//! verification commit each count one task of the daily budget (section 4).
35//! A result is always taken, even past the budget (the overrun is counted);
36//! its `usage` counts toward the swarm's own cap at most as Hive caps it
37//! per claim.
38//!
39//! Verification: Hive draws k verifiers (default 2) from opted-in engines
40//! of other orgs. A verifier commits [`verification_commitment_v2`] (1.9 erratum; v1 still accepted) within 24
41//! hours, then reveals within 24 hours; nobody sees a verdict before
42//! revealing. The contributor's `value` is withheld from verifiers, who
43//! submit their own.
44//!
45//! Errors (existing codes, `reason`): `403 forbidden_right` reasons
46//! `vetoed` (a moderator holds the swarm: claims and renewals) and
47//! `no_handle` (any Swarm write, messages included, by a person without a
48//! handle);
49//! `409 invalid_state` reasons `held` (a result while the swarm is
50//! held), `swarm_closed`, `task_claimed`, `subtask_state`,
51//! `stale_claim`, `verification_state`, `commit_mismatch`,
52//! `material_missing` (a seeded swarm submitted before all its material
53//! is uploaded);
54//! `429 rate_limited` reasons `daily_budget` (section 4), `swarm_budget`,
55//! `limit_exceeded` (section 5).
56
57use serde::{Deserialize, Serialize};
58use serde_json::{Value, json};
59use sha2::{Digest, Sha256};
60
61use super::messages::MessageTrust;
62use super::tasks::UsageV1;
63use super::{canonical_json, require_schema, validate_hex64, validate_id, validate_text};
64use crate::{UtcTimestamp, ValidationError};
65
66pub const SWARM_SCHEMA: &str = "leanctx.hive.swarm/v1";
67pub const SWARM_SUBTASK_LIST_SCHEMA: &str = "leanctx.hive.swarm-subtask-list/v1";
68pub const SWARM_SUBTASK_PROPOSE_SCHEMA: &str = "leanctx.hive.swarm-subtask-propose/v1";
69pub const SWARM_CLAIM_REQUEST_SCHEMA: &str = "leanctx.hive.swarm-claim-request/v1";
70pub const SWARM_CLAIM_RENEW_SCHEMA: &str = "leanctx.hive.swarm-claim-renew/v1";
71pub const SWARM_CLAIM_RELEASE_SCHEMA: &str = "leanctx.hive.swarm-claim-release/v1";
72pub const SWARM_CLAIM_SCHEMA: &str = "leanctx.hive.swarm-claim/v1";
73pub const SWARM_RESULT_SCHEMA: &str = "leanctx.hive.swarm-result/v1";
74pub const SWARM_RESULT_ACK_SCHEMA: &str = "leanctx.hive.swarm-result-ack/v1";
75pub const SWARM_VERIFICATION_LIST_SCHEMA: &str = "leanctx.hive.swarm-verification-list/v1";
76pub const VERIFICATION_COMMIT_SCHEMA: &str = "leanctx.hive.verification-commit/v1";
77/// 1.9 erratum (RB6 L6): the domain-separated commitment
78/// ([`verification_commitment_v2`]); engines send this form.
79pub const VERIFICATION_COMMIT_V2_SCHEMA: &str = "leanctx.hive.verification-commit/v2";
80pub const VERIFICATION_REVEAL_SCHEMA: &str = "leanctx.hive.verification-reveal/v1";
81pub const VERIFICATION_REVEAL_ACK_SCHEMA: &str = "leanctx.hive.verification-reveal-ack/v1";
82pub const MODERATION_REPORT_SCHEMA: &str = "leanctx.hive.moderation-report/v1";
83pub const SWARM_SEED_SCHEMA: &str = "leanctx.hive.swarm-seed/v1";
84
85pub const MAX_RESULT_BODY_CHARS: usize = 50_000;
86pub const MAX_VALUE_TEXT_CHARS: usize = 2000;
87pub const MAX_VALUE_LIST: usize = 1000;
88/// One text item of a `list` value: a single line, printable (R3-3e-1).
89pub const MAX_VALUE_ITEM_CHARS: usize = 500;
90pub const MAX_MATERIAL: usize = 64;
91pub const MAX_SEED_SUBTASKS: usize = 200;
92pub const MIN_CLAIM_MINUTES: u32 = 5;
93pub const MAX_CLAIM_MINUTES: u32 = 15;
94
95fn untrusted(trust: MessageTrust) -> Result<(), ValidationError> {
96    if trust == MessageTrust::Untrusted {
97        Ok(())
98    } else {
99        Err(ValidationError::new("every Swarm payload is untrusted"))
100    }
101}
102
103fn minutes_ok(minutes: u32) -> Result<(), ValidationError> {
104    if (MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
105        Ok(())
106    } else {
107        Err(ValidationError::new("a Swarm claim lasts 5-15 minutes"))
108    }
109}
110
111/// A participant (D6): the handle only.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct HandleRefV1 {
115    pub handle: String,
116}
117
118impl HandleRefV1 {
119    fn validate(&self) -> Result<(), ValidationError> {
120        if super::profiles::handle_ok(&self.handle) {
121            Ok(())
122        } else {
123            Err(ValidationError::new("handle is malformed"))
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum SwarmState {
131    Open,
132    Working,
133    Answered,
134    Closed,
135    #[serde(other)]
136    Unknown,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum SwarmScope {
142    Public,
143    /// Visible only to the opted-in workers of one org; verified inside it.
144    Team,
145    #[serde(other)]
146    Unknown,
147}
148
149/// One file of a swarm's material (fetched by `sha256`).
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct MaterialV1 {
153    pub name: String,
154    /// Media type, e.g. `text/plain`.
155    pub media_type: String,
156    pub sha256: String,
157    pub bytes: u64,
158}
159
160impl MaterialV1 {
161    fn validate(&self) -> Result<(), ValidationError> {
162        validate_text(&self.name, 200, "material name", false)?;
163        validate_text(&self.media_type, 100, "media_type", false)?;
164        validate_hex64(&self.sha256, "material sha256")
165    }
166}
167
168fn validate_material(material: &[MaterialV1]) -> Result<(), ValidationError> {
169    if material.len() > MAX_MATERIAL {
170        return Err(ValidationError::new("at most 64 material files"));
171    }
172    material.iter().try_for_each(MaterialV1::validate)
173}
174
175/// SPDX licence id: 1-64 of `[A-Za-z0-9.+-]`.
176/// S2/S3: a team swarm names its organization exactly as the engine's
177/// lease does ([`super::lease::HiveLeaseV1`] `org_id`, `org_` and 32
178/// lowercase hex digits), so the engine compares the strings; a public
179/// swarm never names one.
180fn scope_org(scope: SwarmScope, org_id: Option<&str>) -> Result<(), ValidationError> {
181    match (scope, org_id) {
182        (SwarmScope::Public, None) => Ok(()),
183        (SwarmScope::Team, Some(org)) => validate_id(org, "org_id"),
184        (SwarmScope::Public, Some(_)) => Err(ValidationError::new("a public swarm names no org")),
185        (SwarmScope::Team, None) => Err(ValidationError::new("a team swarm names its org_id")),
186        (SwarmScope::Unknown, _) => Err(ValidationError::new("unknown swarm scope")),
187    }
188}
189
190/// S2: whether a swarm may reach an engine whose lease names `lease_org_id`:
191/// every public one, a team one only in its own organization. Hive checks
192/// it; the engine checks it again and reports a team swarm of another org.
193fn reaches(scope: SwarmScope, org_id: Option<&str>, lease_org_id: &str) -> bool {
194    match scope {
195        SwarmScope::Public => true,
196        SwarmScope::Team => org_id == Some(lease_org_id),
197        SwarmScope::Unknown => false,
198    }
199}
200
201fn licence_ok(licence: &str) -> bool {
202    (1..=64).contains(&licence.len())
203        && licence
204            .bytes()
205            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'+' | b'-'))
206}
207
208/// `GET /v1/swarms/{swarm_id}`: text and material only.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct SwarmV1 {
211    pub schema: String,
212    pub swarm_id: String,
213    pub title: String,
214    pub body: String,
215    #[serde(default)]
216    pub topics: Vec<String>,
217    /// SPDX id; `CC-BY-4.0` by default.
218    pub licence: String,
219    pub scope: SwarmScope,
220    /// S2: the team swarm's organization; absent on a public swarm.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub org_id: Option<String>,
223    pub state: SwarmState,
224    pub owner: HandleRefV1,
225    #[serde(default)]
226    pub material: Vec<MaterialV1>,
227    /// The answer document's revision (0 before the first merge).
228    pub answer_revision: u64,
229    pub trust: MessageTrust,
230}
231
232impl SwarmV1 {
233    pub fn validate(&self) -> Result<(), ValidationError> {
234        require_schema(&self.schema, SWARM_SCHEMA)?;
235        validate_id(&self.swarm_id, "swarm_id")?;
236        validate_text(&self.title, 200, "title", false)?;
237        validate_text(&self.body, 20_000, "body", true)?;
238        if !licence_ok(&self.licence) {
239            return Err(ValidationError::new("licence is an SPDX id"));
240        }
241        scope_org(self.scope, self.org_id.as_deref())?;
242        self.owner.validate()?;
243        validate_material(&self.material)?;
244        untrusted(self.trust)
245    }
246
247    /// S2: see [`SwarmRefV1::reaches`].
248    #[must_use]
249    pub fn reaches(&self, lease_org_id: &str) -> bool {
250        reaches(self.scope, self.org_id.as_deref(), lease_org_id)
251    }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum SwarmSubtaskState {
257    /// Proposed by an agent; the swarm owner decides.
258    Proposed,
259    Open,
260    Claimed,
261    Verifying,
262    Accepted,
263    Rejected,
264    #[serde(other)]
265    Unknown,
266}
267
268/// The deterministic part of a result, compared between contributor and
269/// verifiers.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case")]
272pub enum ExpectedKind {
273    /// An integer.
274    Number,
275    /// Up to 1000 strings or integers.
276    List,
277    /// 64 lowercase hex (a sha256).
278    Hash,
279    /// Up to 2000 characters.
280    Text,
281    #[serde(other)]
282    Unknown,
283}
284
285/// `value` must match `kind`, and is absent without one.
286pub fn validate_value(
287    kind: Option<ExpectedKind>,
288    value: Option<&Value>,
289) -> Result<(), ValidationError> {
290    let ok = match (kind, value) {
291        (None, None) => true,
292        (Some(ExpectedKind::Number), Some(v)) => v.is_i64() || v.is_u64(),
293        (Some(ExpectedKind::List), Some(v)) => v.as_array().is_some_and(|items| {
294            items.len() <= MAX_VALUE_LIST
295                && items.iter().all(|item| {
296                    item.is_i64()
297                        || item.is_u64()
298                        // R3-3e-1: a text item is read like any other text
299                        // (no invisible or control characters).
300                        || item.as_str().is_some_and(|s| {
301                            validate_text(s, MAX_VALUE_ITEM_CHARS, "value", false).is_ok()
302                        })
303                })
304        }),
305        (Some(ExpectedKind::Hash), Some(v)) => v
306            .as_str()
307            .is_some_and(|s| validate_hex64(s, "value").is_ok()),
308        (Some(ExpectedKind::Text), Some(v)) => v
309            .as_str()
310            .is_some_and(|s| validate_text(s, MAX_VALUE_TEXT_CHARS, "value", true).is_ok()),
311        _ => false,
312    };
313    if ok {
314        Ok(())
315    } else {
316        Err(ValidationError::new(
317            "value must match expected_kind and is absent without one",
318        ))
319    }
320}
321
322/// A swarm as listings name it. S3: `scope` lets the engine mark a team
323/// swarm apart from a public one.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct SwarmRefV1 {
326    pub swarm_id: String,
327    pub title: String,
328    pub licence: String,
329    pub scope: SwarmScope,
330    /// S2: the team swarm's organization; absent on a public swarm.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub org_id: Option<String>,
333}
334
335impl SwarmRefV1 {
336    pub fn validate(&self) -> Result<(), ValidationError> {
337        validate_id(&self.swarm_id, "swarm_id")?;
338        validate_text(&self.title, 200, "title", false)?;
339        if !licence_ok(&self.licence) {
340            return Err(ValidationError::new("licence is an SPDX id"));
341        }
342        scope_org(self.scope, self.org_id.as_deref())
343    }
344
345    /// S2: whether this swarm may reach an engine whose lease names
346    /// `lease_org_id`; a team swarm of another organization does not, and
347    /// the engine leaves it out and reports it.
348    #[must_use]
349    pub fn reaches(&self, lease_org_id: &str) -> bool {
350        reaches(self.scope, self.org_id.as_deref(), lease_org_id)
351    }
352}
353
354/// One subtask as an engine sees it.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct SwarmSubtaskV1 {
357    pub subtask_id: String,
358    pub swarm_id: String,
359    pub number: u64,
360    pub title: String,
361    /// The acceptance criterion.
362    pub acceptance: String,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub expected_kind: Option<ExpectedKind>,
365    pub state: SwarmSubtaskState,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub claimed_by: Option<HandleRefV1>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub claim_until: Option<UtcTimestamp>,
370    /// Whether the asking engine holds the claim.
371    pub mine: bool,
372    /// In listings.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub swarm: Option<SwarmRefV1>,
375}
376
377impl SwarmSubtaskV1 {
378    pub fn validate(&self) -> Result<(), ValidationError> {
379        validate_id(&self.subtask_id, "subtask_id")?;
380        validate_id(&self.swarm_id, "swarm_id")?;
381        validate_text(&self.title, 200, "title", false)?;
382        validate_text(&self.acceptance, 2000, "acceptance", true)?;
383        if let Some(by) = &self.claimed_by {
384            by.validate()?;
385        }
386        if self.claimed_by.is_some() != (self.state == SwarmSubtaskState::Claimed) {
387            return Err(ValidationError::new(
388                "claimed_by comes exactly with a claimed subtask",
389            ));
390        }
391        if let Some(swarm) = &self.swarm {
392            swarm.validate()?;
393            if swarm.swarm_id != self.swarm_id {
394                return Err(ValidationError::new("swarm does not match swarm_id"));
395            }
396        }
397        Ok(())
398    }
399}
400
401/// `GET /v1/swarms/subtasks`: open subtasks the engine may claim, and its
402/// own running claims.
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct SwarmSubtaskListV1 {
405    pub schema: String,
406    pub items: Vec<SwarmSubtaskV1>,
407    pub trust: MessageTrust,
408}
409
410impl SwarmSubtaskListV1 {
411    pub fn validate(&self) -> Result<(), ValidationError> {
412        require_schema(&self.schema, SWARM_SUBTASK_LIST_SCHEMA)?;
413        if self.items.len() > 200 {
414            return Err(ValidationError::new("at most 200 subtasks per page"));
415        }
416        self.items.iter().try_for_each(SwarmSubtaskV1::validate)?;
417        untrusted(self.trust)
418    }
419}
420
421/// An agent proposes one decomposition step; the swarm owner decides.
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct SwarmSubtaskProposeV1 {
425    pub schema: String,
426    pub title: String,
427    pub acceptance: String,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub expected_kind: Option<ExpectedKind>,
430}
431
432impl SwarmSubtaskProposeV1 {
433    pub fn validate(&self) -> Result<(), ValidationError> {
434        require_schema(&self.schema, SWARM_SUBTASK_PROPOSE_SCHEMA)?;
435        validate_text(&self.title, 200, "title", false)?;
436        validate_text(&self.acceptance, 2000, "acceptance", true)?;
437        if self.expected_kind == Some(ExpectedKind::Unknown) {
438            return Err(ValidationError::new("unknown expected_kind"));
439        }
440        Ok(())
441    }
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(deny_unknown_fields)]
446pub struct SwarmClaimRequestV1 {
447    pub schema: String,
448    /// The opt-in the engine last read (section 4).
449    pub optin_id: String,
450    pub minutes: u32,
451}
452
453impl SwarmClaimRequestV1 {
454    pub fn validate(&self) -> Result<(), ValidationError> {
455        require_schema(&self.schema, SWARM_CLAIM_REQUEST_SCHEMA)?;
456        validate_id(&self.optin_id, "optin_id")?;
457        minutes_ok(self.minutes)
458    }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(deny_unknown_fields)]
463pub struct SwarmClaimRenewV1 {
464    pub schema: String,
465    pub fencing_token: u64,
466    pub minutes: u32,
467}
468
469impl SwarmClaimRenewV1 {
470    pub fn validate(&self) -> Result<(), ValidationError> {
471        require_schema(&self.schema, SWARM_CLAIM_RENEW_SCHEMA)?;
472        minutes_ok(self.minutes)
473    }
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(deny_unknown_fields)]
478pub struct SwarmClaimReleaseV1 {
479    pub schema: String,
480    pub fencing_token: u64,
481}
482
483impl SwarmClaimReleaseV1 {
484    pub fn validate(&self) -> Result<(), ValidationError> {
485        require_schema(&self.schema, SWARM_CLAIM_RELEASE_SCHEMA)
486    }
487}
488
489/// A granted or renewed claim.
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct SwarmClaimV1 {
492    pub schema: String,
493    pub subtask_id: String,
494    pub swarm_id: String,
495    pub number: u64,
496    pub fencing_token: u64,
497    pub until: UtcTimestamp,
498    pub trust: MessageTrust,
499}
500
501impl SwarmClaimV1 {
502    pub fn validate(&self) -> Result<(), ValidationError> {
503        require_schema(&self.schema, SWARM_CLAIM_SCHEMA)?;
504        validate_id(&self.subtask_id, "subtask_id")?;
505        validate_id(&self.swarm_id, "swarm_id")?;
506        untrusted(self.trust)
507    }
508}
509
510/// `POST …/result`: the holder's result. Hive runs no screening on it;
511/// the engine's filter packs run before the upload like for any upload.
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513#[serde(deny_unknown_fields)]
514pub struct SwarmResultRequestV1 {
515    pub schema: String,
516    pub fencing_token: u64,
517    pub summary: String,
518    #[serde(default)]
519    pub body: String,
520    /// The deterministic part, when the subtask has an `expected_kind`.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub value: Option<Value>,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub usage: Option<UsageV1>,
525}
526
527impl SwarmResultRequestV1 {
528    /// Shape checks; `value` against the subtask's kind is checked with
529    /// [`validate_value`] by whoever knows the subtask.
530    pub fn validate(&self) -> Result<(), ValidationError> {
531        require_schema(&self.schema, SWARM_RESULT_SCHEMA)?;
532        validate_text(&self.summary, 500, "summary", false)?;
533        if !self.body.is_empty() {
534            validate_text(&self.body, MAX_RESULT_BODY_CHARS, "body", true)?;
535        }
536        Ok(())
537    }
538
539    /// `sha256` hex of `canonical_json({summary, body, value|null})`: the
540    /// result's id in verification and export.
541    pub fn digest(&self) -> Result<String, ValidationError> {
542        let doc = json!({"summary": self.summary, "body": self.body, "value": self.value});
543        Ok(hex(&Sha256::digest(canonical_json(&doc)?)))
544    }
545}
546
547fn hex(bytes: &[u8]) -> String {
548    bytes.iter().map(|b| format!("{b:02x}")).collect()
549}
550
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552pub struct SwarmResultAckV1 {
553    pub schema: String,
554    pub result_id: String,
555    /// [`SwarmResultRequestV1::digest`].
556    pub sha256: String,
557    pub state: SwarmSubtaskState,
558    pub verifiers_assigned: u32,
559    pub trust: MessageTrust,
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
563#[serde(rename_all = "snake_case")]
564pub enum VerificationState {
565    Assigned,
566    Committed,
567    Revealed,
568    #[serde(other)]
569    Unknown,
570}
571
572/// What a verifier sees of the result: never the contributor's `value`.
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574pub struct VerifiedResultV1 {
575    pub summary: String,
576    pub body: String,
577    pub by: HandleRefV1,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581pub struct VerifiedSubtaskV1 {
582    pub title: String,
583    pub acceptance: String,
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub expected_kind: Option<ExpectedKind>,
586}
587
588/// One verification assignment of the asking engine.
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct SwarmVerificationV1 {
591    pub verification_id: String,
592    pub state: VerificationState,
593    /// The end of the current phase (commit or reveal, 24 hours each).
594    pub due_at: UtcTimestamp,
595    pub result: VerifiedResultV1,
596    pub subtask: VerifiedSubtaskV1,
597    pub swarm: SwarmRefV1,
598}
599
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601pub struct SwarmVerificationListV1 {
602    pub schema: String,
603    pub items: Vec<SwarmVerificationV1>,
604    pub trust: MessageTrust,
605}
606
607impl SwarmVerificationListV1 {
608    pub fn validate(&self) -> Result<(), ValidationError> {
609        require_schema(&self.schema, SWARM_VERIFICATION_LIST_SCHEMA)?;
610        for item in &self.items {
611            validate_id(&item.verification_id, "verification_id")?;
612            item.result.by.validate()?;
613            item.swarm.validate()?;
614            if item.state == VerificationState::Revealed {
615                return Err(ValidationError::new("revealed assignments are not listed"));
616            }
617        }
618        untrusted(self.trust)
619    }
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(rename_all = "snake_case")]
624pub enum Verdict {
625    Agree,
626    Disagree,
627    #[serde(other)]
628    Unknown,
629}
630
631/// The commitment a verifier sends first: `sha256` hex of
632/// `canonical_json({nonce, value|null, verdict})`, `nonce` as sent in the
633/// reveal (standard padded base64 of at least 16 random bytes).
634pub fn verification_commitment(
635    verdict: Verdict,
636    value: Option<&Value>,
637    nonce: &str,
638) -> Result<String, ValidationError> {
639    let doc = json!({"nonce": nonce, "value": value, "verdict": verdict});
640    Ok(hex(&Sha256::digest(canonical_json(&doc)?)))
641}
642
643/// 1.9 erratum (RB6 L6): `sha256` hex of
644/// `canonical_json({domain: "leanctx.hive.verification-commit/v2",
645/// verification_id, nonce, value|null, verdict})`. Bound to one assignment
646/// and to its purpose, a commitment cannot be replayed on another
647/// verification or read as another kind of hash.
648pub fn verification_commitment_v2(
649    verification_id: &str,
650    verdict: Verdict,
651    value: Option<&Value>,
652    nonce: &str,
653) -> Result<String, ValidationError> {
654    let doc = json!({
655        "domain": VERIFICATION_COMMIT_V2_SCHEMA,
656        "verification_id": verification_id,
657        "nonce": nonce,
658        "value": value,
659        "verdict": verdict,
660    });
661    Ok(hex(&Sha256::digest(canonical_json(&doc)?)))
662}
663
664/// The commit. `schema` names the form: v2 ([`verification_commitment_v2`],
665/// what engines send) or v1 ([`verification_commitment`], still accepted
666/// from engines built before the erratum). Hive checks the reveal against
667/// the form the commit named.
668#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct VerificationCommitV1 {
671    pub schema: String,
672    pub sha256: String,
673}
674
675impl VerificationCommitV1 {
676    pub fn validate(&self) -> Result<(), ValidationError> {
677        if self.schema != VERIFICATION_COMMIT_V2_SCHEMA {
678            require_schema(&self.schema, VERIFICATION_COMMIT_SCHEMA)?;
679        }
680        validate_hex64(&self.sha256, "sha256")
681    }
682}
683
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(deny_unknown_fields)]
686pub struct VerificationRevealV1 {
687    pub schema: String,
688    pub verdict: Verdict,
689    /// The verifier's own deterministic value, when the subtask has a kind.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub value: Option<Value>,
692    pub nonce: String,
693    /// Required with `disagree`.
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub reason: Option<String>,
696}
697
698impl VerificationRevealV1 {
699    pub fn validate(&self) -> Result<(), ValidationError> {
700        require_schema(&self.schema, VERIFICATION_REVEAL_SCHEMA)?;
701        if self.verdict == Verdict::Unknown {
702            return Err(ValidationError::new("verdict is agree or disagree"));
703        }
704        // 24 characters of padded base64 hold at least 16 bytes.
705        if self.nonce.len() < 24 {
706            return Err(ValidationError::new("nonce holds at least 16 random bytes"));
707        }
708        super::validate_b64_bounded(&self.nonce, 128, "nonce")?;
709        match (&self.reason, self.verdict) {
710            (Some(reason), _) => validate_text(reason, 2000, "reason", true),
711            (None, Verdict::Disagree) => Err(ValidationError::new("disagree needs a reason")),
712            (None, _) => Ok(()),
713        }
714    }
715
716    pub fn commitment(&self) -> Result<String, ValidationError> {
717        verification_commitment(self.verdict, self.value.as_ref(), &self.nonce)
718    }
719
720    /// The v2 commitment for the assignment `verification_id`.
721    pub fn commitment_v2(&self, verification_id: &str) -> Result<String, ValidationError> {
722        verification_commitment_v2(
723            verification_id,
724            self.verdict,
725            self.value.as_ref(),
726            &self.nonce,
727        )
728    }
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(rename_all = "snake_case")]
733pub enum ResultOutcome {
734    Verifying,
735    Accepted,
736    Rejected,
737    /// Verdicts split; the swarm owner decides.
738    Split,
739    #[serde(other)]
740    Unknown,
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
744pub struct VerificationRevealAckV1 {
745    pub schema: String,
746    pub state: VerificationState,
747    pub result_state: ResultOutcome,
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(rename_all = "snake_case")]
752pub enum ReportTarget {
753    Swarm,
754    Subtask,
755    Result,
756    Message,
757    #[serde(other)]
758    Unknown,
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
762#[serde(rename_all = "snake_case")]
763pub enum ReportReason {
764    Spam,
765    Abuse,
766    SecretOrPii,
767    Illegal,
768    /// Prompt injection in swarm content (X5).
769    Injection,
770    Other,
771    #[serde(other)]
772    Unknown,
773}
774
775/// `POST /v1/swarms/reports` (and the same body on the web): reports hide
776/// content automatically past a weighted threshold; moderators decide.
777#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
778#[serde(deny_unknown_fields)]
779pub struct ModerationReportV1 {
780    pub schema: String,
781    pub target_kind: ReportTarget,
782    pub target_id: String,
783    pub reason: ReportReason,
784    #[serde(default, skip_serializing_if = "Option::is_none")]
785    pub note: Option<String>,
786}
787
788impl ModerationReportV1 {
789    pub fn validate(&self) -> Result<(), ValidationError> {
790        require_schema(&self.schema, MODERATION_REPORT_SCHEMA)?;
791        if self.target_kind == ReportTarget::Unknown || self.reason == ReportReason::Unknown {
792            return Err(ValidationError::new("unknown report target or reason"));
793        }
794        validate_id(&self.target_id, "target_id")?;
795        if let Some(note) = &self.note {
796            validate_text(note, 1000, "note", true)?;
797        }
798        Ok(())
799    }
800}
801
802/// One pre-built subtask of a seed.
803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
804#[serde(deny_unknown_fields)]
805pub struct SeedSubtaskV1 {
806    pub title: String,
807    pub acceptance: String,
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub expected_kind: Option<ExpectedKind>,
810}
811
812/// O5: a seeded swarm as one import file (admin), created in `draft`;
813/// importing the same canonical content again is a no-op. `material` is
814/// the manifest only: a moderator uploads each file to
815/// `POST /api/v1/swarms/{id}/material`, checked against
816/// `sha256` and `bytes`.
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818#[serde(deny_unknown_fields)]
819pub struct SwarmSeedV1 {
820    pub schema: String,
821    pub title: String,
822    pub body: String,
823    #[serde(default)]
824    pub topics: Vec<String>,
825    pub licence: String,
826    pub subtasks: Vec<SeedSubtaskV1>,
827    #[serde(default)]
828    pub material: Vec<MaterialV1>,
829}
830
831impl SwarmSeedV1 {
832    pub fn validate(&self) -> Result<(), ValidationError> {
833        require_schema(&self.schema, SWARM_SEED_SCHEMA)?;
834        validate_text(&self.title, 200, "title", false)?;
835        validate_text(&self.body, 20_000, "body", true)?;
836        if !licence_ok(&self.licence) {
837            return Err(ValidationError::new("licence is an SPDX id"));
838        }
839        if self.subtasks.is_empty() || self.subtasks.len() > MAX_SEED_SUBTASKS {
840            return Err(ValidationError::new("a seed has 1-200 subtasks"));
841        }
842        for subtask in &self.subtasks {
843            validate_text(&subtask.title, 200, "title", false)?;
844            validate_text(&subtask.acceptance, 2000, "acceptance", true)?;
845        }
846        validate_material(&self.material)
847    }
848
849    /// `sha256` hex of the canonical seed: the import's idempotency key.
850    pub fn digest(&self) -> Result<String, ValidationError> {
851        let value = serde_json::to_value(self)
852            .map_err(|error| ValidationError::new(format!("seed: {error}")))?;
853        Ok(hex(&Sha256::digest(canonical_json(&value)?)))
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    #[test]
862    fn a_team_swarm_names_its_org_and_reaches_only_it() {
863        let swarm = |scope, org_id: Option<&str>| SwarmRefV1 {
864            swarm_id: "sw_01jbretry".into(),
865            title: "Retry budget".into(),
866            licence: "CC-BY-4.0".into(),
867            scope,
868            org_id: org_id.map(str::to_string),
869        };
870        let public = swarm(SwarmScope::Public, None);
871        assert!(
872            public.validate().is_ok() && public.reaches("org_acme") && public.reaches("org_other")
873        );
874        let team = swarm(SwarmScope::Team, Some("org_acme"));
875        assert!(team.validate().is_ok());
876        assert!(team.reaches("org_acme") && !team.reaches("org_other"));
877        // A public swarm never names an org; a team swarm always does.
878        assert!(
879            swarm(SwarmScope::Public, Some("org_acme"))
880                .validate()
881                .is_err()
882        );
883        assert!(swarm(SwarmScope::Team, None).validate().is_err());
884        let unknown = swarm(SwarmScope::Unknown, None);
885        assert!(unknown.validate().is_err() && !unknown.reaches("org_acme"));
886    }
887
888    #[test]
889    fn a_reveal_reproduces_its_commitment_and_a_changed_verdict_does_not() {
890        let reveal = VerificationRevealV1 {
891            schema: VERIFICATION_REVEAL_SCHEMA.into(),
892            verdict: Verdict::Agree,
893            value: Some(json!(3)),
894            nonce: "AAECAwQFBgcICQoLDA0ODxAREhM=".into(),
895            reason: None,
896        };
897        reveal.validate().unwrap();
898        let commitment = reveal.commitment().unwrap();
899        assert_eq!(
900            commitment,
901            verification_commitment(Verdict::Agree, Some(&json!(3)), &reveal.nonce).unwrap()
902        );
903        assert_ne!(
904            commitment,
905            verification_commitment(Verdict::Disagree, Some(&json!(3)), &reveal.nonce).unwrap()
906        );
907    }
908
909    #[test]
910    fn values_follow_the_expected_kind() {
911        assert!(validate_value(None, None).is_ok());
912        assert!(validate_value(Some(ExpectedKind::Number), Some(&json!(5))).is_ok());
913        assert!(validate_value(Some(ExpectedKind::Number), Some(&json!("5"))).is_err());
914        assert!(validate_value(None, Some(&json!(5))).is_err());
915        assert!(validate_value(Some(ExpectedKind::Hash), Some(&json!("A".repeat(64)))).is_err());
916        // R3-3e-1: list text items are printable single lines of bounded length.
917        let list = Some(ExpectedKind::List);
918        assert!(validate_value(list, Some(&json!(["retry", 3, "backoff"]))).is_ok());
919        for bad in [
920            json!(["ok\u{E0041}\u{E0042}"]),
921            json!(["a\u{202E}b"]),
922            json!(["a\u{200B}b"]),
923            json!(["two\nlines"]),
924            json!([""]),
925            json!(["x".repeat(MAX_VALUE_ITEM_CHARS + 1)]),
926        ] {
927            assert!(validate_value(list, Some(&bad)).is_err(), "{bad}");
928        }
929        assert!(validate_value(Some(ExpectedKind::Text), Some(&json!("a\u{E0041}"))).is_err());
930    }
931}