Skip to main content

lean_ctx_protocol/hive/
tasks.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Tasks, claims and results (C0 1.9, section 1).
4//!
5//! A task is a question or a piece of work in one Hive vault with an
6//! acceptance criterion. People create tasks on the web. An engine may only
7//! *propose* one, for its agent or for a contradiction it found, and a person
8//! approves it before anyone can claim it.
9//!
10//! The rules:
11//! - A task has at most one claim at a time. The claim is a bus lease
12//!   (`kind task`, section 2), so Hold, Veto and Stop end it like any lease.
13//! - The holder reports short summaries (never prompts) and submits one
14//!   result with evidence. Every write carries the claim's fencing token, so
15//!   a stale holder is refused.
16//! - A result is a proposal (`origin task`), with its knowledge change if it
17//!   brings one.
18//! - The claiming instance never decides on it, not through its instance
19//!   token either. A person decides (the instance's owner included), or an
20//!   auto-merge threshold. A threshold counts independent confirmations: in
21//!   Team and swarms people and agents of other owners (Team: one by
22//!   default, configurable per vault), and in Personal sessions on the owner's
23//!   other instances (one by default): never the claiming instance, in any
24//!   of its sessions (D1 per instance).
25//! - A signal (message, offer) never changes a task's state.
26//!
27//! ```text
28//! proposed --approve--> open --claim--> claimed --result--> submitted --accept--> done
29//!    |                   ^  ^              |                     |
30//!    +--reject--> rejected  +--release / expiry / veto / stop ---+   +--decline--> open
31//! proposed/open/claimed --cancel (people)--> cancelled
32//! ```
33//!
34//! Hive v5 (additive): a result ends the task at once, `done`, or `failed`
35//! when the request says `outcome: failed`; no person decides on it. Task
36//! views carry a [`TaskLifecycle`] next to `state` (`queued → claimed →
37//! running → completed | failed | cancelled`), and a result references the
38//! log `seq` of its knowledge instead of a proposal (`proposal` 0). A 1.x
39//! Hive answers `submitted` and sends no `lifecycle`; an engine sends
40//! `outcome` only to a Hive whose task views carry `lifecycle`.
41//!
42//! Engine routes, all under `/v1/vaults/{vault_id}/tasks`:
43//!
44//! | Route | Body | Answer | Lease right |
45//! |---|---|---|---|
46//! | `GET` (`?state=…&role=…&mine=true`) | | [`TaskListV1`] | `tasks.read` |
47//! | `GET /{number}` | | [`TaskV1`] | `tasks.read` |
48//! | `POST` | [`TaskProposeRequestV1`] | [`TaskAckV1`] (`proposed`) | `tasks.propose` |
49//! | `POST /{number}/claim` | [`TaskClaimRequestV1`] | [`TaskClaimAckV1`] | `tasks.work` |
50//! | `POST /{number}/claim/renew` | [`TaskClaimRenewRequestV1`] | [`TaskClaimAckV1`] | `tasks.work` |
51//! | `POST /{number}/release` | [`TaskReleaseRequestV1`] | [`TaskAckV1`] (`open`) | `tasks.work` |
52//! | `POST /{number}/updates` | [`TaskUpdateRequestV1`] | [`TaskAckV1`] | `tasks.work` |
53//! | `POST /{number}/result` | [`TaskResultRequestV1`] | [`TaskAckV1`] (`submitted`; v5 `done` or `failed`) | `tasks.work` |
54//!
55//! Errors carry `number` (`HiveErrorDetailV1::number`):
56//! - `409 invalid_state`, `reason`:
57//!   - `task_claimed`: another claim holds the task;
58//!   - `task_not_open`: claim on a task that is not `open`;
59//!   - `stale_claim`: the lease id or fencing token is not the current claim's;
60//!   - `task_closed`: any write to a `done`, `rejected` or `cancelled` task;
61//!   - `reviewer_not_independent`: a confirmation that a threshold may not count;
62//!   - `cross_network`: an object of another network (org bus vs a swarm);
63//!     for message replies Hive answers `404 not_found` instead (section 2).
64//! - `403 forbidden_right`, `reason`:
65//!   - `not_permitted`: the task's role or the owner's worker consent does
66//!     not allow this agent;
67//!   - `vetoed`: a veto or stop holds this agent;
68//!   - `optin_scope`: the engine's opt-in does not cover this vault
69//!     (section 4).
70//! - `409 invalid_state`, `reason` `optin_changed` or `optin_inactive`
71//!   (section 4).
72//! - `429 rate_limited`, `reason` `daily_budget`: the opt-in's daily
73//!   budget is used up (`retry_after_s` until it resets).
74//!
75//! Sealed vaults: an engine sends no readable text. A proposed task, an
76//! update or a result carries `sealed` instead of the plaintext fields,
77//! encrypted under the vault key with the AAD
78//! `vault_id|<task number>|epoch|content_schema`. A proposed task, which
79//! has no number yet, uses `0`. Evidence travels inside the sealed result.
80//! Text that people type on the web is Hive-visible by nature, as for gaps.
81
82use serde::{Deserialize, Serialize};
83
84use super::anchor::CodeAnchorV1;
85use super::knowledge::DraftProposalV1;
86use super::sealed::SealedBlobV1;
87use super::{require_schema, validate_hex64, validate_id, validate_text};
88use crate::{UtcTimestamp, ValidationError};
89
90pub const TASK_SCHEMA: &str = "leanctx.hive.task/v1";
91pub const TASK_LIST_SCHEMA: &str = "leanctx.hive.task-list/v1";
92pub const TASK_ACK_SCHEMA: &str = "leanctx.hive.task-ack/v1";
93pub const TASK_PROPOSE_SCHEMA: &str = "leanctx.hive.task-propose/v1";
94pub const TASK_CLAIM_REQUEST_SCHEMA: &str = "leanctx.hive.task-claim-request/v1";
95pub const TASK_CLAIM_RENEW_SCHEMA: &str = "leanctx.hive.task-claim-renew/v1";
96pub const TASK_CLAIM_ACK_SCHEMA: &str = "leanctx.hive.task-claim/v1";
97pub const TASK_RELEASE_SCHEMA: &str = "leanctx.hive.task-release/v1";
98pub const TASK_UPDATE_SCHEMA: &str = "leanctx.hive.task-update/v1";
99pub const TASK_RESULT_SCHEMA: &str = "leanctx.hive.task-result/v1";
100
101/// `content_schema` of a sealed proposed task: `{title, goal?, acceptance}`.
102pub const SEALED_TASK_CONTENT_SCHEMA: &str = "leanctx.hive.sealed-task/v1";
103/// `content_schema` of a sealed update: `{summary}`.
104pub const SEALED_TASK_UPDATE_SCHEMA: &str = "leanctx.hive.sealed-task-update/v1";
105/// `content_schema` of a sealed result: `{summary, evidence}`.
106pub const SEALED_TASK_RESULT_SCHEMA: &str = "leanctx.hive.sealed-task-result/v1";
107
108pub const MIN_TITLE_CHARS: usize = 5;
109pub const MAX_TITLE_CHARS: usize = 180;
110pub const MAX_GOAL_CHARS: usize = 2000;
111pub const MAX_ACCEPTANCE_CHARS: usize = 2000;
112/// Updates and result summaries are summaries, not prompts: one line.
113pub const MAX_SUMMARY_CHARS: usize = 500;
114pub const MAX_REASON_CHARS: usize = 280;
115pub const MAX_EVIDENCE: usize = 20;
116pub const MAX_EVIDENCE_REF_CHARS: usize = 512;
117/// Updates a task view carries, newest last.
118pub const MAX_VIEW_UPDATES: usize = 20;
119pub const MAX_TASKS_PAGE: usize = 100;
120/// A claim is a bus lease: 5 to 15 minutes ([`super::bus::MAX_BUS_LEASE_MS`]),
121/// renewed while work goes on, so a silent agent frees the task quickly.
122pub const MIN_CLAIM_MINUTES: u32 = 5;
123pub const MAX_CLAIM_MINUTES: u32 = 15;
124
125/// Lifecycle of a task.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum TaskState {
129    /// Proposed by an engine or the system; a person must approve it.
130    Proposed,
131    Open,
132    Claimed,
133    /// A result waits for its decision.
134    Submitted,
135    Done,
136    /// A proposed task a person declined.
137    Rejected,
138    Cancelled,
139    /// Hive v5: the holder ended its claim with a failure (terminal).
140    Failed,
141    #[serde(other)]
142    Unknown,
143}
144
145/// Hive v5 lifecycle of a task, sent next to `state` (additive):
146/// `queued → claimed → running → completed | failed | cancelled`.
147/// `running` = claimed, and the holder reported progress under this claim.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum TaskLifecycle {
151    Queued,
152    Claimed,
153    Running,
154    Completed,
155    Failed,
156    Cancelled,
157    #[serde(other)]
158    Unknown,
159}
160
161/// How the holder ends its claim with a result (Hive v5); absent on the
162/// wire means `completed`.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum TaskOutcome {
166    #[default]
167    Completed,
168    Failed,
169}
170
171/// Who created a task, and why.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum TaskOriginKind {
175    /// A person, on the web.
176    Person,
177    /// An engine, for its agent.
178    Agent,
179    /// An engine that found two facts contradicting each other.
180    Contradiction,
181    /// A question no agent answered (section 2).
182    Question,
183    /// New knowledge that stayed in quarantine past its review window
184    /// (section 3); `facts` names the quarantined facts.
185    QuarantineTimeout,
186    #[serde(other)]
187    Unknown,
188}
189
190/// Origin of a task. `facts` names exactly two distinct fact ids for a
191/// contradiction and the quarantined facts for a quarantine timeout;
192/// `message` names the question's message id for a question.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct TaskOriginV1 {
196    pub kind: TaskOriginKind,
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub facts: Vec<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub message: Option<String>,
201}
202
203impl TaskOriginV1 {
204    /// What an engine may send: `agent`, or `contradiction` with two facts.
205    pub fn validate_from_engine(&self) -> Result<(), ValidationError> {
206        if self.message.is_some() {
207            return Err(ValidationError::new("engines do not create question tasks"));
208        }
209        match self.kind {
210            TaskOriginKind::Agent if self.facts.is_empty() => Ok(()),
211            TaskOriginKind::Agent => Err(ValidationError::new("an agent task names no facts")),
212            TaskOriginKind::Contradiction => {
213                if self.facts.len() != 2 || self.facts[0] == self.facts[1] {
214                    return Err(ValidationError::new(
215                        "a contradiction names exactly two distinct facts",
216                    ));
217                }
218                self.facts
219                    .iter()
220                    .try_for_each(|id| validate_id(id, "facts"))
221            }
222            _ => Err(ValidationError::new(
223                "engines propose agent or contradiction tasks only",
224            )),
225        }
226    }
227}
228
229/// An actor as an engine sees it: never a user id or an email.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct TaskActorV1 {
232    pub kind: TaskActorKind,
233    /// A person's display name, or an instance label.
234    pub label: String,
235    /// The agent on that instance (bus agent id), for instances.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub agent: Option<String>,
238    /// The person who owns that instance (display name), for instances.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub owner: Option<String>,
241    /// The model the agent reported for itself; unverified.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub model: Option<String>,
244    /// Whether this is the instance asking.
245    pub mine: bool,
246    /// 1.9 (section 5): the agent's profile, for instances.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub profile_id: Option<String>,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "snake_case")]
253pub enum TaskActorKind {
254    Person,
255    Instance,
256    #[serde(other)]
257    Unknown,
258}
259
260/// One piece of evidence. `reference` is what the kind names:
261/// - `receipt`: a receipt digest (64 lowercase hex), checked by Hive;
262/// - `commit`: a commit id (7-64 hex);
263/// - `ci_run`: a CI run reference (an `https` URL or a provider id);
264/// - `file_anchor`: absent, the anchor is in `anchor`;
265/// - `url`: an `https` URL, which Hive never fetches;
266/// - `fact`: a fact id of the vault.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct EvidenceV1 {
270    pub kind: EvidenceKind,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub reference: Option<String>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub anchor: Option<CodeAnchorV1>,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum EvidenceKind {
280    Receipt,
281    Commit,
282    CiRun,
283    FileAnchor,
284    Url,
285    Fact,
286    #[serde(other)]
287    Unknown,
288}
289
290impl EvidenceV1 {
291    pub fn validate(&self) -> Result<(), ValidationError> {
292        let refuse = || ValidationError::new(format!("evidence does not fit kind {:?}", self.kind));
293        if self.kind == EvidenceKind::FileAnchor {
294            return match (&self.reference, &self.anchor) {
295                (None, Some(anchor)) => anchor.validate(),
296                _ => Err(refuse()),
297            };
298        }
299        if self.anchor.is_some() {
300            return Err(refuse());
301        }
302        let reference = self.reference.as_deref().ok_or_else(refuse)?;
303        validate_text(
304            reference,
305            MAX_EVIDENCE_REF_CHARS,
306            "evidence.reference",
307            false,
308        )?;
309        let https = |r: &str| r.starts_with("https://") && !r.contains(char::is_whitespace);
310        let ok = match self.kind {
311            EvidenceKind::Receipt => validate_hex64(reference, "evidence.reference").is_ok(),
312            EvidenceKind::Commit => {
313                (7..=64).contains(&reference.len())
314                    && reference
315                        .bytes()
316                        .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
317            }
318            EvidenceKind::CiRun => https(reference) || validate_id(reference, "ci_run").is_ok(),
319            EvidenceKind::Url => https(reference),
320            EvidenceKind::Fact => validate_id(reference, "fact").is_ok(),
321            EvidenceKind::FileAnchor | EvidenceKind::Unknown => false,
322        };
323        if ok { Ok(()) } else { Err(refuse()) }
324    }
325}
326
327fn validate_evidence(evidence: &[EvidenceV1]) -> Result<(), ValidationError> {
328    if evidence.is_empty() || evidence.len() > MAX_EVIDENCE {
329        return Err(ValidationError::new(format!(
330            "a result carries 1-{MAX_EVIDENCE} pieces of evidence"
331        )));
332    }
333    for (i, item) in evidence.iter().enumerate() {
334        item.validate()?;
335        if evidence[..i].contains(item) {
336            return Err(ValidationError::new("evidence repeats"));
337        }
338    }
339    Ok(())
340}
341
342fn validate_title(title: &str) -> Result<(), ValidationError> {
343    validate_text(title, MAX_TITLE_CHARS, "title", false)?;
344    if title.trim().chars().count() < MIN_TITLE_CHARS {
345        return Err(ValidationError::new(format!(
346            "title must have at least {MIN_TITLE_CHARS} characters"
347        )));
348    }
349    Ok(())
350}
351
352/// An agent role slug (section 5): 1-64 of `[a-z0-9-]`, starting with a letter.
353pub fn validate_role(role: &str) -> Result<(), ValidationError> {
354    let bytes = role.as_bytes();
355    if !bytes.is_empty()
356        && bytes.len() <= 64
357        && bytes[0].is_ascii_lowercase()
358        && bytes
359            .iter()
360            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
361    {
362        Ok(())
363    } else {
364        Err(ValidationError::new(
365            "role must be 1-64 characters of [a-z0-9-] starting with a letter",
366        ))
367    }
368}
369
370/// Exactly one of plaintext (`plain`) or `sealed` with the given schema.
371fn one_of(plain: bool, sealed: Option<&SealedBlobV1>, schema: &str) -> Result<(), ValidationError> {
372    match (plain, sealed) {
373        (true, None) => Ok(()),
374        (false, Some(blob)) => {
375            blob.validate()?;
376            if blob.content_schema == schema {
377                Ok(())
378            } else {
379                Err(ValidationError::new(format!(
380                    "sealed content must be {schema}"
381                )))
382            }
383        }
384        (true, Some(_)) => Err(ValidationError::new("sealed writes carry no plaintext")),
385        (false, None) => Err(ValidationError::new("text or sealed content is required")),
386    }
387}
388
389/// The claim a write acts under: the lease id and its fencing token.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct ClaimRefV1 {
393    pub lease_id: String,
394    pub fencing_token: u64,
395}
396
397impl ClaimRefV1 {
398    pub fn validate(&self) -> Result<(), ValidationError> {
399        validate_id(&self.lease_id, "lease_id")?;
400        if self.fencing_token == 0 {
401            return Err(ValidationError::new("fencing tokens start at 1"));
402        }
403        Ok(())
404    }
405}
406
407fn validate_minutes(minutes: u32) -> Result<(), ValidationError> {
408    if (MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
409        Ok(())
410    } else {
411        Err(ValidationError::new(format!(
412            "minutes must be {MIN_CLAIM_MINUTES}-{MAX_CLAIM_MINUTES}"
413        )))
414    }
415}
416
417/// `POST /v1/vaults/{vault_id}/tasks`: an engine proposes a task.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct TaskProposeRequestV1 {
421    pub schema: String,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub title: Option<String>,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub goal: Option<String>,
426    /// When the task is done; required in the clear.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub acceptance: Option<String>,
429    /// The agent role it is for (section 5); any role when absent.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub role: Option<String>,
432    pub origin: TaskOriginV1,
433    /// Opaque, per-session id (no host or user data).
434    pub session_ref: String,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub sealed: Option<SealedBlobV1>,
437}
438
439impl TaskProposeRequestV1 {
440    pub fn validate(&self) -> Result<(), ValidationError> {
441        require_schema(&self.schema, TASK_PROPOSE_SCHEMA)?;
442        self.origin.validate_from_engine()?;
443        validate_id(&self.session_ref, "session_ref")?;
444        if let Some(role) = &self.role {
445            validate_role(role)?;
446        }
447        let plain = self.title.is_some() || self.goal.is_some() || self.acceptance.is_some();
448        one_of(plain, self.sealed.as_ref(), SEALED_TASK_CONTENT_SCHEMA)?;
449        if plain {
450            validate_title(self.title.as_deref().unwrap_or_default())?;
451            if let Some(goal) = &self.goal {
452                validate_text(goal, MAX_GOAL_CHARS, "goal", true)?;
453            }
454            validate_text(
455                self.acceptance.as_deref().unwrap_or_default(),
456                MAX_ACCEPTANCE_CHARS,
457                "acceptance",
458                true,
459            )?;
460        }
461        Ok(())
462    }
463}
464
465/// `POST …/{number}/claim`.
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(deny_unknown_fields)]
468pub struct TaskClaimRequestV1 {
469    pub schema: String,
470    /// The bus agent id the claim is for.
471    pub agent: String,
472    pub session_ref: String,
473    pub minutes: u32,
474    /// The engine opt-in the claim works under (section 4), whose budget
475    /// it draws on: the `optin_id` the engine last read. Hive requires it
476    /// and refuses a stale one (`optin_changed`).
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub optin_id: Option<String>,
479}
480
481impl TaskClaimRequestV1 {
482    pub fn validate(&self) -> Result<(), ValidationError> {
483        require_schema(&self.schema, TASK_CLAIM_REQUEST_SCHEMA)?;
484        validate_id(&self.agent, "agent")?;
485        validate_id(&self.session_ref, "session_ref")?;
486        if let Some(optin) = &self.optin_id {
487            validate_id(optin, "optin_id")?;
488        }
489        validate_minutes(self.minutes)
490    }
491}
492
493/// `POST …/{number}/claim/renew`. 1.9 erratum: the renewed claim keeps its
494/// lease id and fencing token (the bus lease rule).
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(deny_unknown_fields)]
497pub struct TaskClaimRenewRequestV1 {
498    pub schema: String,
499    pub claim: ClaimRefV1,
500    pub minutes: u32,
501}
502
503impl TaskClaimRenewRequestV1 {
504    pub fn validate(&self) -> Result<(), ValidationError> {
505        require_schema(&self.schema, TASK_CLAIM_RENEW_SCHEMA)?;
506        self.claim.validate()?;
507        validate_minutes(self.minutes)
508    }
509}
510
511/// The one claim on a task, as a task view shows it.
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513pub struct TaskClaimV1 {
514    pub holder: TaskActorV1,
515    pub since: UtcTimestamp,
516    pub until: UtcTimestamp,
517}
518
519/// Answer of claim and renew: the lease the holder writes under.
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct TaskClaimAckV1 {
522    pub schema: String,
523    pub number: u64,
524    pub claim: ClaimRefV1,
525    pub until: UtcTimestamp,
526}
527
528/// `POST …/{number}/release`: give the task back (blocked, out of scope).
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(deny_unknown_fields)]
531pub struct TaskReleaseRequestV1 {
532    pub schema: String,
533    pub claim: ClaimRefV1,
534    /// Why, in one line; absent for sealed vaults.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub reason: Option<String>,
537}
538
539impl TaskReleaseRequestV1 {
540    pub fn validate(&self) -> Result<(), ValidationError> {
541        require_schema(&self.schema, TASK_RELEASE_SCHEMA)?;
542        self.claim.validate()?;
543        if let Some(reason) = &self.reason {
544            validate_text(reason, MAX_REASON_CHARS, "reason", false)?;
545        }
546        Ok(())
547    }
548}
549
550/// What the work cost the agent's owner so far (self-reported): model
551/// tokens and, if known, US dollars in millionths (integers only, as
552/// everything under a signature).
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
554#[serde(deny_unknown_fields)]
555pub struct UsageV1 {
556    pub tokens: u64,
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub usd_micros: Option<u64>,
559}
560
561/// `POST …/{number}/updates`: one short summary from the holder.
562#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
563#[serde(deny_unknown_fields)]
564pub struct TaskUpdateRequestV1 {
565    pub schema: String,
566    pub claim: ClaimRefV1,
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub summary: Option<String>,
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub sealed: Option<SealedBlobV1>,
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub usage: Option<UsageV1>,
573}
574
575impl TaskUpdateRequestV1 {
576    pub fn validate(&self) -> Result<(), ValidationError> {
577        require_schema(&self.schema, TASK_UPDATE_SCHEMA)?;
578        self.claim.validate()?;
579        one_of(
580            self.summary.is_some(),
581            self.sealed.as_ref(),
582            SEALED_TASK_UPDATE_SCHEMA,
583        )?;
584        if let Some(summary) = &self.summary {
585            validate_text(summary, MAX_SUMMARY_CHARS, "summary", false)?;
586        }
587        Ok(())
588    }
589}
590
591/// `POST …/{number}/result`: the holder's result. It ends the claim; a 1.x
592/// Hive turns it into a proposal (`origin task`), Hive v5 ends the task
593/// (`done`, or `failed` with `outcome: failed`). `draft` is its knowledge
594/// change, if it brings one, exactly as a regular draft (sealed rules
595/// included); a failed result brings none.
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct TaskResultRequestV1 {
599    pub schema: String,
600    pub claim: ClaimRefV1,
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub summary: Option<String>,
603    #[serde(default, skip_serializing_if = "Vec::is_empty")]
604    pub evidence: Vec<EvidenceV1>,
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub draft: Option<DraftProposalV1>,
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub sealed: Option<SealedBlobV1>,
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub usage: Option<UsageV1>,
611    /// Hive v5; absent = `completed`. A 1.x Hive refuses the field
612    /// (unknown fields are denied), so it is sent only to a v5 Hive.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub outcome: Option<TaskOutcome>,
615}
616
617impl TaskResultRequestV1 {
618    pub fn validate(&self) -> Result<(), ValidationError> {
619        require_schema(&self.schema, TASK_RESULT_SCHEMA)?;
620        self.claim.validate()?;
621        let plain = self.summary.is_some() || !self.evidence.is_empty();
622        one_of(plain, self.sealed.as_ref(), SEALED_TASK_RESULT_SCHEMA)?;
623        if plain {
624            validate_text(
625                self.summary.as_deref().unwrap_or_default(),
626                MAX_SUMMARY_CHARS,
627                "summary",
628                false,
629            )?;
630            validate_evidence(&self.evidence)?;
631        }
632        if let Some(draft) = &self.draft {
633            if self.outcome == Some(TaskOutcome::Failed) {
634                return Err(ValidationError::new("a failed result brings no draft"));
635            }
636            draft.validate()?;
637            if draft.context_patch.sealed.is_some() != self.sealed.is_some() {
638                return Err(ValidationError::new(
639                    "a result and its draft are both sealed or both in the clear",
640                ));
641            }
642        }
643        Ok(())
644    }
645}
646
647/// Answer of every task write: the number and the state afterwards.
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649pub struct TaskAckV1 {
650    pub schema: String,
651    pub number: u64,
652    pub state: TaskState,
653}
654
655/// One update as a task view shows it.
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657pub struct TaskUpdateV1 {
658    pub by: TaskActorV1,
659    pub at: UtcTimestamp,
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub summary: Option<String>,
662    #[serde(default, skip_serializing_if = "Option::is_none")]
663    pub sealed: Option<SealedBlobV1>,
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub usage: Option<UsageV1>,
666}
667
668/// Evidence as Hive shows it: `verified` is set by Hive (a `receipt`
669/// whose digest and signature it checked), never by a client.
670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
671pub struct EvidenceViewV1 {
672    pub kind: EvidenceKind,
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub reference: Option<String>,
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub anchor: Option<CodeAnchorV1>,
677    pub verified: bool,
678}
679
680/// The submitted result, as a task view shows it.
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682pub struct TaskResultV1 {
683    pub by: TaskActorV1,
684    pub at: UtcTimestamp,
685    /// The proposal that carries it; 0 when there is none (Hive v5: the
686    /// result completed the task directly).
687    pub proposal: u64,
688    /// Hive v5: the log seq of the result's appended knowledge.
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub seq: Option<u64>,
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub summary: Option<String>,
693    #[serde(default, skip_serializing_if = "Vec::is_empty")]
694    pub evidence: Vec<EvidenceViewV1>,
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub sealed: Option<SealedBlobV1>,
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub usage: Option<UsageV1>,
699}
700
701/// Who decided on a result: a person, or the auto-merge threshold (Personal).
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(tag = "kind", rename_all = "snake_case")]
704pub enum DecidedByV1 {
705    Person {
706        label: String,
707    },
708    Threshold,
709    #[serde(other)]
710    Unknown,
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
714#[serde(rename_all = "snake_case")]
715pub enum TaskDecisionOutcome {
716    Accepted,
717    Declined,
718    #[serde(other)]
719    Unknown,
720}
721
722/// The decision on a result.
723#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
724pub struct TaskDecisionV1 {
725    pub outcome: TaskDecisionOutcome,
726    pub decided_by: DecidedByV1,
727    pub at: UtcTimestamp,
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub reason: Option<String>,
730}
731
732/// `GET /v1/vaults/{vault_id}/tasks/{number}`.
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734pub struct TaskV1 {
735    pub schema: String,
736    pub vault_id: String,
737    pub number: u64,
738    pub state: TaskState,
739    /// Hive v5 lifecycle (additive; absent from a 1.x Hive).
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub lifecycle: Option<TaskLifecycle>,
742    /// Absent when an engine proposed it in a sealed vault.
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub title: Option<String>,
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub goal: Option<String>,
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub acceptance: Option<String>,
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub sealed: Option<SealedBlobV1>,
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub role: Option<String>,
753    pub origin: TaskOriginV1,
754    pub created_by: TaskActorV1,
755    pub created_at: UtcTimestamp,
756    /// The person who approved a proposed task.
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub approved_by: Option<TaskActorV1>,
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub claim: Option<TaskClaimV1>,
761    /// Newest last, at most [`MAX_VIEW_UPDATES`].
762    #[serde(default, skip_serializing_if = "Vec::is_empty")]
763    pub updates: Vec<TaskUpdateV1>,
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub result: Option<TaskResultV1>,
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub decision: Option<TaskDecisionV1>,
768}
769
770impl TaskV1 {
771    pub fn validate(&self) -> Result<(), ValidationError> {
772        require_schema(&self.schema, TASK_SCHEMA)?;
773        super::validate_vault_id(&self.vault_id)?;
774        if self.number == 0 {
775            return Err(ValidationError::new("task numbers start at 1"));
776        }
777        if (self.state == TaskState::Claimed) != self.claim.is_some() {
778            return Err(ValidationError::new(
779                "a task carries a claim exactly while it is claimed",
780            ));
781        }
782        if self.updates.len() > MAX_VIEW_UPDATES {
783            return Err(ValidationError::new("too many updates in a task view"));
784        }
785        if let Some(approver) = &self.approved_by
786            && approver.kind != TaskActorKind::Person
787        {
788            return Err(ValidationError::new("only a person approves a task"));
789        }
790        if matches!(self.state, TaskState::Submitted | TaskState::Done) && self.result.is_none() {
791            return Err(ValidationError::new(
792                "a submitted or done task carries its result",
793            ));
794        }
795        Ok(())
796    }
797}
798
799/// One task in a listing.
800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
801pub struct TaskSummaryV1 {
802    pub number: u64,
803    pub state: TaskState,
804    /// Hive v5 lifecycle (additive; absent from a 1.x Hive).
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub lifecycle: Option<TaskLifecycle>,
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub title: Option<String>,
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub role: Option<String>,
811    pub origin: TaskOriginKind,
812    pub updated_at: UtcTimestamp,
813    /// Whether this instance holds its claim.
814    pub mine: bool,
815}
816
817/// `GET /v1/vaults/{vault_id}/tasks`.
818#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
819pub struct TaskListV1 {
820    pub schema: String,
821    pub vault_id: String,
822    pub tasks: Vec<TaskSummaryV1>,
823}
824
825impl TaskListV1 {
826    pub fn validate(&self) -> Result<(), ValidationError> {
827        require_schema(&self.schema, TASK_LIST_SCHEMA)?;
828        super::validate_vault_id(&self.vault_id)?;
829        if self.tasks.len() > MAX_TASKS_PAGE {
830            return Err(ValidationError::new("too many tasks in one page"));
831        }
832        Ok(())
833    }
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    fn propose() -> TaskProposeRequestV1 {
841        TaskProposeRequestV1 {
842            schema: TASK_PROPOSE_SCHEMA.into(),
843            title: Some("Explain the retry budget".into()),
844            goal: None,
845            acceptance: Some("A fact with an anchor on retry.rs".into()),
846            role: None,
847            origin: TaskOriginV1 {
848                kind: TaskOriginKind::Agent,
849                facts: Vec::new(),
850                message: None,
851            },
852            session_ref: "s-1".into(),
853            sealed: None,
854        }
855    }
856
857    fn claim() -> ClaimRefV1 {
858        ClaimRefV1 {
859            lease_id: "lease-1".into(),
860            fencing_token: 3,
861        }
862    }
863
864    #[test]
865    fn engines_propose_only_agent_or_contradiction_tasks() {
866        assert!(propose().validate().is_ok());
867        let mut person = propose();
868        person.origin.kind = TaskOriginKind::Person;
869        assert!(person.validate().is_err());
870        let mut contradiction = propose();
871        contradiction.origin = TaskOriginV1 {
872            kind: TaskOriginKind::Contradiction,
873            facts: vec!["f-1".into(), "f-1".into()],
874            message: None,
875        };
876        assert!(contradiction.validate().is_err());
877        contradiction.origin.facts[1] = "f-2".into();
878        assert!(contradiction.validate().is_ok());
879    }
880
881    #[test]
882    fn a_result_needs_evidence_that_fits_its_kind() {
883        let mut result = TaskResultRequestV1 {
884            schema: TASK_RESULT_SCHEMA.into(),
885            claim: claim(),
886            summary: Some("Done".into()),
887            evidence: Vec::new(),
888            draft: None,
889            sealed: None,
890            usage: None,
891            outcome: None,
892        };
893        assert!(result.validate().is_err(), "no evidence");
894        let r = |kind, reference: &str| EvidenceV1 {
895            kind,
896            reference: Some(reference.into()),
897            anchor: None,
898        };
899        for (evidence, ok) in [
900            (r(EvidenceKind::Commit, "9f01cf32a3"), true),
901            (r(EvidenceKind::Commit, "main"), false),
902            (r(EvidenceKind::Url, "http://example.com"), false),
903            (r(EvidenceKind::Receipt, &"a".repeat(64)), true),
904            (r(EvidenceKind::FileAnchor, "src/retry.rs"), false),
905        ] {
906            result.evidence = vec![evidence.clone()];
907            assert_eq!(result.validate().is_ok(), ok, "{evidence:?}");
908        }
909        result.claim.fencing_token = 0;
910        result.evidence = vec![r(EvidenceKind::Commit, "9f01cf32a3")];
911        assert!(result.validate().is_err(), "fencing tokens start at 1");
912    }
913
914    #[test]
915    fn summaries_are_one_line() {
916        let update = |summary: &str| TaskUpdateRequestV1 {
917            schema: TASK_UPDATE_SCHEMA.into(),
918            claim: claim(),
919            summary: Some(summary.into()),
920            sealed: None,
921            usage: None,
922        };
923        assert!(update("Reproduced the timeout").validate().is_ok());
924        assert!(update("line one\nline two").validate().is_err());
925        assert!(
926            update(&"x".repeat(MAX_SUMMARY_CHARS + 1))
927                .validate()
928                .is_err()
929        );
930    }
931
932    #[test]
933    fn decided_by_is_a_person_or_the_threshold() {
934        let person: DecidedByV1 =
935            serde_json::from_value(serde_json::json!({"kind": "person", "label": "Ada"})).unwrap();
936        assert_eq!(
937            person,
938            DecidedByV1::Person {
939                label: "Ada".into()
940            }
941        );
942        let threshold: DecidedByV1 =
943            serde_json::from_value(serde_json::json!({"kind": "threshold"})).unwrap();
944        assert_eq!(threshold, DecidedByV1::Threshold);
945    }
946
947    fn fixture(name: &str) -> serde_json::Value {
948        let path = format!("{}/tests/fixtures/hive/{name}", env!("CARGO_MANIFEST_DIR"));
949        serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
950    }
951
952    /// Hive v5: `outcome` is sent only when set (a 1.x Hive denies the
953    /// field), and a failed result brings no draft.
954    #[test]
955    fn a_failed_result_names_its_outcome_and_brings_no_draft() {
956        let mut result: TaskResultRequestV1 =
957            serde_json::from_value(fixture("task_result.request.json")).unwrap();
958        assert_eq!(result.outcome, None);
959        assert!(
960            serde_json::to_value(&result)
961                .unwrap()
962                .get("outcome")
963                .is_none(),
964            "a completed result sends no outcome"
965        );
966        result.outcome = Some(TaskOutcome::Failed);
967        assert!(result.validate().is_ok());
968        assert_eq!(serde_json::to_value(&result).unwrap()["outcome"], "failed");
969        result.draft =
970            Some(serde_json::from_value(fixture("proposal.import.request.json")).unwrap());
971        assert_eq!(
972            result.validate().unwrap_err().to_string(),
973            "a failed result brings no draft"
974        );
975        result.outcome = Some(TaskOutcome::Completed);
976        assert!(
977            result.validate().is_ok(),
978            "a completed result may bring one"
979        );
980    }
981
982    /// A 1.x view (`submitted`, no `lifecycle`, a proposal) and a v5 view
983    /// (`failed`, `lifecycle`, `seq` with proposal 0) both parse; unknown
984    /// lifecycle words stay tolerated.
985    #[test]
986    fn task_views_of_both_lines_parse() {
987        let old: TaskV1 = serde_json::from_value(fixture("task.done.response.json")).unwrap();
988        assert_eq!(old.lifecycle, None);
989        assert_eq!(old.result.as_ref().unwrap().seq, None);
990        assert!(
991            serde_json::to_value(&old)
992                .unwrap()
993                .get("lifecycle")
994                .is_none()
995        );
996
997        let mut v5 = fixture("task.done.response.json");
998        v5["state"] = "failed".into();
999        v5["lifecycle"] = "failed".into();
1000        v5["result"]["proposal"] = 0.into();
1001        v5["result"]["seq"] = 42.into();
1002        let v5: TaskV1 = serde_json::from_value(v5).unwrap();
1003        assert_eq!(v5.state, TaskState::Failed);
1004        assert_eq!(v5.lifecycle, Some(TaskLifecycle::Failed));
1005        let result = v5.result.as_ref().unwrap();
1006        assert_eq!((result.proposal, result.seq), (0, Some(42)));
1007        assert!(v5.validate().is_ok());
1008
1009        let mut list = fixture("task_list.response.json");
1010        list["tasks"][0]["lifecycle"] = "paused_someday".into();
1011        let list: TaskListV1 = serde_json::from_value(list).unwrap();
1012        assert_eq!(list.tasks[0].lifecycle, Some(TaskLifecycle::Unknown));
1013        let state: TaskState = serde_json::from_value("someday".into()).unwrap();
1014        assert_eq!(state, TaskState::Unknown);
1015    }
1016}