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 people and agents of other owners (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&priority=…`) | | [`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//! | `POST /{number}/verify` | [`TaskVerifyRequestV1`] | [`TaskAckV1`] (`done` or `open`; C0 2.2) | `tasks.work` |
55//! | `PATCH /{number}` | [`TaskFieldsRequestV1`] | [`TaskAckV1`] (C0 2.2) | `tasks.propose` |
56//!
57//! C0 2.2: a result leaves the task `submitted` (`failed` with `outcome:
58//! failed`) until another agent verifies it (E48): `accept` → `done`,
59//! `reject` → `open`. The verifier is never the result's worker (`403
60//! forbidden_right`, `reason` `self_verify`), and only a submitted task
61//! takes a verdict (`409 invalid_state`, `reason` `not_submitted`).
62//!
63//! Errors carry `number` (`HiveErrorDetailV1::number`):
64//! - `409 invalid_state`, `reason`:
65//!   - `task_claimed`: another claim holds the task;
66//!   - `task_not_open`: claim on a task that is not `open`;
67//!   - `stale_claim`: the lease id or fencing token is not the current claim's;
68//!   - `task_closed`: any write to a `done`, `rejected` or `cancelled` task;
69//!   - `reviewer_not_independent`: a confirmation that a threshold may not count.
70//! - `403 forbidden_right`, `reason`:
71//!   - `not_permitted`: the task's role or the owner's worker consent does
72//!     not allow this agent;
73//!   - `vetoed`: a veto or stop holds this agent;
74//!   - `optin_scope`: the engine's opt-in does not cover this vault
75//!     (section 4).
76//! - `409 invalid_state`, `reason` `optin_changed` or `optin_inactive`
77//!   (section 4).
78//! - `429 rate_limited`, `reason` `daily_budget`: the opt-in's daily
79//!   budget is used up (`retry_after_s` until it resets).
80//!
81//! A proposed task, an update and a result carry their text in the clear.
82
83use serde::{Deserialize, Serialize};
84
85use super::anchor::CodeAnchorV1;
86use super::knowledge::DraftProposalV1;
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/// C0 2.2 E48: another agent's verdict on a submitted result.
101pub const TASK_VERIFY_SCHEMA: &str = "leanctx.hive.task-verify/v1";
102/// 3.12.3: the submitter corrects its submitted result (summary, evidence).
103pub const TASK_AMEND_SCHEMA: &str = "leanctx.hive.task-amend/v1";
104/// C0 2.2 E47 `PATCH`: a task's priority and labels.
105pub const TASK_FIELDS_SCHEMA: &str = "leanctx.hive.task-fields/v1";
106
107pub const MIN_TITLE_CHARS: usize = 5;
108pub const MAX_TITLE_CHARS: usize = 180;
109pub const MAX_GOAL_CHARS: usize = 2000;
110pub const MAX_ACCEPTANCE_CHARS: usize = 2000;
111/// Updates and result summaries are summaries, not prompts: one line.
112pub const MAX_SUMMARY_CHARS: usize = 500;
113pub const MAX_REASON_CHARS: usize = 280;
114/// A verifier's reason (C0 2.2 E48), required on `reject`.
115pub const MAX_VERIFY_REASON_CHARS: usize = 2000;
116pub const MAX_TASK_LABELS: usize = 16;
117pub const MAX_EVIDENCE: usize = 20;
118pub const MAX_EVIDENCE_REF_CHARS: usize = 512;
119/// Updates a task view carries, newest last.
120pub const MAX_VIEW_UPDATES: usize = 20;
121pub const MAX_TASKS_PAGE: usize = 100;
122/// A claim is a bus lease: 5 to 15 minutes ([`super::bus::MAX_BUS_LEASE_MS`]),
123/// renewed while work goes on, so a silent agent frees the task quickly.
124pub const MIN_CLAIM_MINUTES: u32 = 5;
125pub const MAX_CLAIM_MINUTES: u32 = 15;
126
127/// Lifecycle of a task.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum TaskState {
131    /// Proposed by an engine or the system; a person must approve it.
132    Proposed,
133    Open,
134    Claimed,
135    /// A result waits for its decision.
136    Submitted,
137    Done,
138    /// A proposed task a person declined.
139    Rejected,
140    Cancelled,
141    /// Hive v5: the holder ended its claim with a failure (terminal).
142    Failed,
143    #[serde(other)]
144    Unknown,
145}
146
147/// Hive v5 lifecycle of a task, sent next to `state` (additive):
148/// `queued → claimed → running → completed | failed | cancelled`.
149/// `running` = claimed, and the holder reported progress under this claim.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum TaskLifecycle {
153    Queued,
154    Claimed,
155    Running,
156    /// C0 2.2: a result waits for a second agent's check.
157    Submitted,
158    Completed,
159    Failed,
160    Cancelled,
161    #[serde(other)]
162    Unknown,
163}
164
165/// How the holder ends its claim with a result (Hive v5); absent on the
166/// wire means `completed`.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum TaskOutcome {
170    #[default]
171    Completed,
172    Failed,
173}
174
175/// Who created a task, and why.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum TaskOriginKind {
179    /// A person, on the web.
180    Person,
181    /// An engine, for its agent.
182    Agent,
183    /// An engine that found two facts contradicting each other.
184    Contradiction,
185    /// A question no agent answered (section 2).
186    Question,
187    /// Any other origin.
188    #[serde(other)]
189    Unknown,
190}
191
192/// Origin of a task. `facts` names exactly two distinct fact ids for a
193/// contradiction; `message` names the question's message id for a
194/// question.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct TaskOriginV1 {
198    pub kind: TaskOriginKind,
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub facts: Vec<String>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub message: Option<String>,
203}
204
205impl TaskOriginV1 {
206    /// What an engine may send: `agent`, or `contradiction` with two facts.
207    pub fn validate_from_engine(&self) -> Result<(), ValidationError> {
208        if self.message.is_some() {
209            return Err(ValidationError::new("engines do not create question tasks"));
210        }
211        match self.kind {
212            TaskOriginKind::Agent if self.facts.is_empty() => Ok(()),
213            TaskOriginKind::Agent => Err(ValidationError::new("an agent task names no facts")),
214            TaskOriginKind::Contradiction => {
215                if self.facts.len() != 2 || self.facts[0] == self.facts[1] {
216                    return Err(ValidationError::new(
217                        "a contradiction names exactly two distinct facts",
218                    ));
219                }
220                self.facts
221                    .iter()
222                    .try_for_each(|id| validate_id(id, "facts"))
223            }
224            _ => Err(ValidationError::new(
225                "engines propose agent or contradiction tasks only",
226            )),
227        }
228    }
229}
230
231/// An actor as an engine sees it: never a user id or an email.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct TaskActorV1 {
234    pub kind: TaskActorKind,
235    /// A person's display name, or an instance label.
236    pub label: String,
237    /// The agent on that instance (bus agent id), for instances.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub agent: Option<String>,
240    /// The person who owns that instance (display name), for instances.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub owner: Option<String>,
243    /// The model the agent reported for itself; unverified.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub model: Option<String>,
246    /// Whether this is the instance asking.
247    pub mine: bool,
248    /// 1.9 (section 5): the agent's profile, for instances.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub profile_id: Option<String>,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum TaskActorKind {
256    Person,
257    Instance,
258    #[serde(other)]
259    Unknown,
260}
261
262/// One piece of evidence. `reference` is what the kind names:
263/// - `receipt`: a receipt digest (64 lowercase hex), checked by Hive;
264/// - `commit`: a commit id (7-64 hex);
265/// - `ci_run`: a CI run reference (an `https` URL or a provider id);
266/// - `file_anchor`: absent, the anchor is in `anchor`;
267/// - `url`: an `https` URL, which Hive never fetches;
268/// - `fact`: a fact id of the vault.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct EvidenceV1 {
272    pub kind: EvidenceKind,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub reference: Option<String>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub anchor: Option<CodeAnchorV1>,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
280#[serde(rename_all = "snake_case")]
281pub enum EvidenceKind {
282    Receipt,
283    Commit,
284    CiRun,
285    FileAnchor,
286    Url,
287    Fact,
288    #[serde(other)]
289    Unknown,
290}
291
292impl EvidenceV1 {
293    pub fn validate(&self) -> Result<(), ValidationError> {
294        let refuse = || ValidationError::new(format!("evidence does not fit kind {:?}", self.kind));
295        if self.kind == EvidenceKind::FileAnchor {
296            return match (&self.reference, &self.anchor) {
297                (None, Some(anchor)) => anchor.validate(),
298                _ => Err(refuse()),
299            };
300        }
301        if self.anchor.is_some() {
302            return Err(refuse());
303        }
304        let reference = self.reference.as_deref().ok_or_else(refuse)?;
305        validate_text(
306            reference,
307            MAX_EVIDENCE_REF_CHARS,
308            "evidence.reference",
309            false,
310        )?;
311        let https = |r: &str| r.starts_with("https://") && !r.contains(char::is_whitespace);
312        let ok = match self.kind {
313            EvidenceKind::Receipt => validate_hex64(reference, "evidence.reference").is_ok(),
314            EvidenceKind::Commit => {
315                (7..=64).contains(&reference.len())
316                    && reference
317                        .bytes()
318                        .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
319            }
320            EvidenceKind::CiRun => https(reference) || validate_id(reference, "ci_run").is_ok(),
321            EvidenceKind::Url => https(reference),
322            EvidenceKind::Fact => validate_id(reference, "fact").is_ok(),
323            EvidenceKind::FileAnchor | EvidenceKind::Unknown => false,
324        };
325        if ok { Ok(()) } else { Err(refuse()) }
326    }
327}
328
329fn validate_evidence(evidence: &[EvidenceV1]) -> Result<(), ValidationError> {
330    if evidence.is_empty() || evidence.len() > MAX_EVIDENCE {
331        return Err(ValidationError::new(format!(
332            "a result carries 1-{MAX_EVIDENCE} pieces of evidence"
333        )));
334    }
335    for (i, item) in evidence.iter().enumerate() {
336        item.validate()?;
337        if evidence[..i].contains(item) {
338            return Err(ValidationError::new("evidence repeats"));
339        }
340    }
341    Ok(())
342}
343
344fn validate_title(title: &str) -> Result<(), ValidationError> {
345    validate_text(title, MAX_TITLE_CHARS, "title", false)?;
346    if title.trim().chars().count() < MIN_TITLE_CHARS {
347        return Err(ValidationError::new(format!(
348            "title must have at least {MIN_TITLE_CHARS} characters"
349        )));
350    }
351    Ok(())
352}
353
354/// An agent role slug (section 5): 1-64 of `[a-z0-9-]`, starting with a letter.
355pub fn validate_role(role: &str) -> Result<(), ValidationError> {
356    let bytes = role.as_bytes();
357    if !bytes.is_empty()
358        && bytes.len() <= 64
359        && bytes[0].is_ascii_lowercase()
360        && bytes
361            .iter()
362            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
363    {
364        Ok(())
365    } else {
366        Err(ValidationError::new(
367            "role must be 1-64 characters of [a-z0-9-] starting with a letter",
368        ))
369    }
370}
371
372/// A task write carries its text (`plain`).
373fn text_required(plain: bool) -> Result<(), ValidationError> {
374    if plain {
375        Ok(())
376    } else {
377        Err(ValidationError::new("text is required"))
378    }
379}
380
381/// The claim a write acts under: the lease id and its fencing token.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct ClaimRefV1 {
385    pub lease_id: String,
386    pub fencing_token: u64,
387}
388
389impl ClaimRefV1 {
390    pub fn validate(&self) -> Result<(), ValidationError> {
391        validate_id(&self.lease_id, "lease_id")?;
392        if self.fencing_token == 0 {
393            return Err(ValidationError::new("fencing tokens start at 1"));
394        }
395        Ok(())
396    }
397}
398
399fn validate_minutes(minutes: u32) -> Result<(), ValidationError> {
400    if (MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
401        Ok(())
402    } else {
403        Err(ValidationError::new(format!(
404            "minutes must be {MIN_CLAIM_MINUTES}-{MAX_CLAIM_MINUTES}"
405        )))
406    }
407}
408
409/// `POST /v1/vaults/{vault_id}/tasks`: an engine proposes a task.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(deny_unknown_fields)]
412pub struct TaskProposeRequestV1 {
413    pub schema: String,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub title: Option<String>,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub goal: Option<String>,
418    /// When the task is done; required in the clear.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub acceptance: Option<String>,
421    /// The agent role it is for (section 5); any role when absent.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub role: Option<String>,
424    pub origin: TaskOriginV1,
425    /// Opaque, per-session id (no host or user data).
426    pub session_ref: String,
427}
428
429impl TaskProposeRequestV1 {
430    pub fn validate(&self) -> Result<(), ValidationError> {
431        require_schema(&self.schema, TASK_PROPOSE_SCHEMA)?;
432        self.origin.validate_from_engine()?;
433        validate_id(&self.session_ref, "session_ref")?;
434        if let Some(role) = &self.role {
435            validate_role(role)?;
436        }
437        let plain = self.title.is_some() || self.goal.is_some() || self.acceptance.is_some();
438        text_required(plain)?;
439        if plain {
440            validate_title(self.title.as_deref().unwrap_or_default())?;
441            if let Some(goal) = &self.goal {
442                validate_text(goal, MAX_GOAL_CHARS, "goal", true)?;
443            }
444            validate_text(
445                self.acceptance.as_deref().unwrap_or_default(),
446                MAX_ACCEPTANCE_CHARS,
447                "acceptance",
448                true,
449            )?;
450        }
451        Ok(())
452    }
453}
454
455/// `POST …/{number}/claim`.
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct TaskClaimRequestV1 {
459    pub schema: String,
460    /// The bus agent id the claim is for.
461    pub agent: String,
462    pub session_ref: String,
463    pub minutes: u32,
464    /// The engine opt-in the claim works under (section 4), whose budget
465    /// it draws on: the `optin_id` the engine last read. Hive requires it
466    /// and refuses a stale one (`optin_changed`).
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub optin_id: Option<String>,
469}
470
471impl TaskClaimRequestV1 {
472    pub fn validate(&self) -> Result<(), ValidationError> {
473        require_schema(&self.schema, TASK_CLAIM_REQUEST_SCHEMA)?;
474        validate_id(&self.agent, "agent")?;
475        validate_id(&self.session_ref, "session_ref")?;
476        if let Some(optin) = &self.optin_id {
477            validate_id(optin, "optin_id")?;
478        }
479        validate_minutes(self.minutes)
480    }
481}
482
483/// `POST …/{number}/claim/renew`. 1.9 erratum: the renewed claim keeps its
484/// lease id and fencing token (the bus lease rule).
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(deny_unknown_fields)]
487pub struct TaskClaimRenewRequestV1 {
488    pub schema: String,
489    pub claim: ClaimRefV1,
490    pub minutes: u32,
491}
492
493impl TaskClaimRenewRequestV1 {
494    pub fn validate(&self) -> Result<(), ValidationError> {
495        require_schema(&self.schema, TASK_CLAIM_RENEW_SCHEMA)?;
496        self.claim.validate()?;
497        validate_minutes(self.minutes)
498    }
499}
500
501/// The one claim on a task, as a task view shows it.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct TaskClaimV1 {
504    pub holder: TaskActorV1,
505    pub since: UtcTimestamp,
506    pub until: UtcTimestamp,
507}
508
509/// Answer of claim and renew: the lease the holder writes under.
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511pub struct TaskClaimAckV1 {
512    pub schema: String,
513    pub number: u64,
514    pub claim: ClaimRefV1,
515    pub until: UtcTimestamp,
516}
517
518/// `POST …/{number}/release`: give the task back (blocked, out of scope).
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(deny_unknown_fields)]
521pub struct TaskReleaseRequestV1 {
522    pub schema: String,
523    pub claim: ClaimRefV1,
524    /// Why, in one line.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub reason: Option<String>,
527}
528
529impl TaskReleaseRequestV1 {
530    pub fn validate(&self) -> Result<(), ValidationError> {
531        require_schema(&self.schema, TASK_RELEASE_SCHEMA)?;
532        self.claim.validate()?;
533        if let Some(reason) = &self.reason {
534            validate_text(reason, MAX_REASON_CHARS, "reason", false)?;
535        }
536        Ok(())
537    }
538}
539
540/// What the work cost the agent's owner so far (self-reported): model
541/// tokens and, if known, US dollars in millionths (integers only, as
542/// everything under a signature).
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(deny_unknown_fields)]
545pub struct UsageV1 {
546    pub tokens: u64,
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub usd_micros: Option<u64>,
549}
550
551/// `POST …/{number}/updates`: one short summary from the holder.
552#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
553#[serde(deny_unknown_fields)]
554pub struct TaskUpdateRequestV1 {
555    pub schema: String,
556    pub claim: ClaimRefV1,
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub summary: Option<String>,
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub usage: Option<UsageV1>,
561}
562
563impl TaskUpdateRequestV1 {
564    pub fn validate(&self) -> Result<(), ValidationError> {
565        require_schema(&self.schema, TASK_UPDATE_SCHEMA)?;
566        self.claim.validate()?;
567        text_required(self.summary.is_some())?;
568        if let Some(summary) = &self.summary {
569            validate_text(summary, MAX_SUMMARY_CHARS, "summary", false)?;
570        }
571        Ok(())
572    }
573}
574
575/// `POST …/{number}/result`: the holder's result. It ends the claim; a 1.x
576/// Hive turns it into a proposal (`origin task`), Hive v5 ends the task
577/// (`done`, or `failed` with `outcome: failed`). `draft` is its knowledge
578/// change, if it brings one, exactly as a regular draft; a failed result
579/// brings none.
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(deny_unknown_fields)]
582pub struct TaskResultRequestV1 {
583    pub schema: String,
584    pub claim: ClaimRefV1,
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub summary: Option<String>,
587    #[serde(default, skip_serializing_if = "Vec::is_empty")]
588    pub evidence: Vec<EvidenceV1>,
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub draft: Option<DraftProposalV1>,
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub usage: Option<UsageV1>,
593    /// Hive v5; absent = `completed`. A 1.x Hive refuses the field
594    /// (unknown fields are denied), so it is sent only to a v5 Hive.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub outcome: Option<TaskOutcome>,
597    /// Process v1 G6: the Instance's part of the record for the judged
598    /// commit (clean locked paths, a green rerun; blocked on the CI pipeline
599    /// Hive reads). Sent only for a process task (a task view with
600    /// `process`), so a Hive without process tasks never sees the field.
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub gate_record: Option<super::gate::G6RecordV1>,
603}
604
605impl TaskResultRequestV1 {
606    pub fn validate(&self) -> Result<(), ValidationError> {
607        require_schema(&self.schema, TASK_RESULT_SCHEMA)?;
608        self.claim.validate()?;
609        let plain = self.summary.is_some() || !self.evidence.is_empty();
610        text_required(plain)?;
611        if plain {
612            validate_text(
613                self.summary.as_deref().unwrap_or_default(),
614                MAX_SUMMARY_CHARS,
615                "summary",
616                false,
617            )?;
618            validate_evidence(&self.evidence)?;
619        }
620        if let Some(draft) = &self.draft {
621            if self.outcome == Some(TaskOutcome::Failed) {
622                return Err(ValidationError::new("a failed result brings no draft"));
623            }
624            draft.validate()?;
625        }
626        if let Some(record) = &self.gate_record {
627            record.validate()?;
628            if !record.ready_for_hive() || self.outcome == Some(TaskOutcome::Failed) {
629                return Err(ValidationError::new(
630                    "a result carries only a G6 record whose Instance checks hold, on a completed result",
631                ));
632            }
633        }
634        Ok(())
635    }
636}
637
638/// Answer of every task write: the number and the state afterwards.
639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
640pub struct TaskAckV1 {
641    pub schema: String,
642    pub number: u64,
643    pub state: TaskState,
644}
645
646/// A verifier's verdict on a submitted result (C0 2.2): `accept` makes the
647/// task `done`, `reject` opens it again.
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
649#[serde(rename_all = "snake_case")]
650pub enum TaskVerdict {
651    Accept,
652    Reject,
653    #[serde(other)]
654    Unknown,
655}
656
657/// `POST …/{number}/verify` (C0 2.2 E48, `tasks.work`): another agent's
658/// verdict. `worker_id` is the verifying agent, never the result's
659/// (`403 self_verify`); `reason` is required on `reject`.
660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(deny_unknown_fields)]
662pub struct TaskVerifyRequestV1 {
663    pub schema: String,
664    pub worker_id: String,
665    pub verdict: TaskVerdict,
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub reason: Option<String>,
668}
669
670fn validate_worker_id(worker_id: &str) -> Result<(), ValidationError> {
671    let worker_ok = (1..=128).contains(&worker_id.len())
672        && worker_id
673            .bytes()
674            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'));
675    if worker_ok {
676        Ok(())
677    } else {
678        Err(ValidationError::new(
679            "worker_id is 1-128 of [A-Za-z0-9._:-]",
680        ))
681    }
682}
683
684impl TaskVerifyRequestV1 {
685    pub fn validate(&self) -> Result<(), ValidationError> {
686        require_schema(&self.schema, TASK_VERIFY_SCHEMA)?;
687        validate_worker_id(&self.worker_id)?;
688        match (self.verdict, &self.reason) {
689            (TaskVerdict::Unknown, _) => Err(ValidationError::new("verdict is accept or reject")),
690            (TaskVerdict::Reject, None) => Err(ValidationError::new("a reject names its reason")),
691            (_, Some(reason)) => validate_text(reason, MAX_VERIFY_REASON_CHARS, "reason", false),
692            (TaskVerdict::Accept, None) => Ok(()),
693        }
694    }
695}
696
697/// The last verdict on a task's result, as a task view shows it (C0 2.2).
698#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
699pub struct TaskVerificationV1 {
700    pub verdict: TaskVerdict,
701    pub by: TaskActorV1,
702    pub at: UtcTimestamp,
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub reason: Option<String>,
705}
706
707/// `POST …/{number}/amend` (3.12.3, `tasks.work`): a result is corrected
708/// without a claim (a submit ends the claim). Two shapes, by state
709/// ([`amend_rule`]):
710/// - `submitted`: the agent that submitted it (`worker_id`) replaces
711///   `evidence` (the whole new list) and optionally `summary`;
712/// - `done`: the submitter, a person or a registered Queen session records
713///   the merge once (`merge_commit`, the merge commit's SHA); nothing else
714///   changes. Never the reviewer (`verified_by`): `403 not_merger`.
715///
716/// Anyone else is `403 not_submitter` ([`may_amend`]). Hive appends a `kind: amendment`
717/// update with `amended_from` (the evidence before) and `amended_to` (the
718/// evidence after, or the merge commit), so the history keeps old → new.
719#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(deny_unknown_fields)]
721pub struct TaskAmendRequestV1 {
722    pub schema: String,
723    pub worker_id: String,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub summary: Option<String>,
726    #[serde(default, skip_serializing_if = "Vec::is_empty")]
727    pub evidence: Vec<EvidenceV1>,
728    /// The merge commit's SHA (7-64 lowercase hex), on a `done` task only.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub merge_commit: Option<String>,
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub reason: Option<String>,
733}
734
735impl TaskAmendRequestV1 {
736    pub fn validate(&self) -> Result<(), ValidationError> {
737        require_schema(&self.schema, TASK_AMEND_SCHEMA)?;
738        validate_worker_id(&self.worker_id)?;
739        if let Some(reason) = &self.reason {
740            validate_text(reason, MAX_REASON_CHARS, "reason", false)?;
741        }
742        match &self.merge_commit {
743            Some(commit) => {
744                if !self.evidence.is_empty() || self.summary.is_some() {
745                    return Err(ValidationError::new(
746                        "a merge record carries only merge_commit (no evidence, no summary)",
747                    ));
748                }
749                merge_evidence(commit).validate()
750            }
751            None => {
752                if let Some(summary) = &self.summary {
753                    validate_text(summary, MAX_SUMMARY_CHARS, "summary", false)?;
754                }
755                validate_evidence(&self.evidence)
756            }
757        }
758    }
759}
760
761/// The merge commit as a piece of evidence (how history shows it).
762pub fn merge_evidence(commit: &str) -> EvidenceV1 {
763    EvidenceV1 {
764        kind: EvidenceKind::Commit,
765        reference: Some(commit.to_owned()),
766        anchor: None,
767    }
768}
769
770/// Why a result cannot be amended (the Hive reason code in brackets).
771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772pub enum AmendRefusal {
773    /// Only a `submitted` or `done` task carries a result to amend
774    /// (`not_amendable`).
775    NotAmendable,
776    /// A `done` task takes only a merge record (`record_merge_only`).
777    RecordMergeOnly,
778    /// A merge record is for a `done` task; a `submitted` one is not merged
779    /// yet (`not_merged_yet`).
780    NotMergedYet,
781    /// The task's merge is already recorded; it is never replaced
782    /// (`merge_recorded`).
783    MergeRecorded,
784}
785
786impl AmendRefusal {
787    pub fn code(self) -> &'static str {
788        match self {
789            Self::NotAmendable => "not_amendable",
790            Self::RecordMergeOnly => "record_merge_only",
791            Self::NotMergedYet => "not_merged_yet",
792            Self::MergeRecorded => "merge_recorded",
793        }
794    }
795}
796
797/// What a task's state allows an amendment of its result: `submitted`
798/// replaces summary and evidence; `done` records the merge once and
799/// changes nothing else; any other state refuses. The one rule for Hive and
800/// the client (the client checks first, Hive enforces); who may amend is
801/// [`may_amend`].
802pub fn amend_rule(task: &TaskV1, request: &TaskAmendRequestV1) -> Result<(), AmendRefusal> {
803    let result = task.result.as_ref().ok_or(AmendRefusal::NotAmendable)?;
804    match (task.state, &request.merge_commit) {
805        (TaskState::Submitted, None) => Ok(()),
806        (TaskState::Submitted, Some(_)) => Err(AmendRefusal::NotMergedYet),
807        (TaskState::Done, Some(_)) if result.merge.is_some() => Err(AmendRefusal::MergeRecorded),
808        (TaskState::Done, Some(_)) => Ok(()),
809        (TaskState::Done, None) => Err(AmendRefusal::RecordMergeOnly),
810        _ => Err(AmendRefusal::NotAmendable),
811    }
812}
813
814/// Who is amending, as the caller established it: the result's submitter,
815/// the reviewer (the actor whose verdict made the task `done`), a person,
816/// or a Queen session the app registered.
817#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
818pub struct AmendCaller {
819    pub submitter: bool,
820    pub reviewer: bool,
821    pub person: bool,
822    pub queen: bool,
823}
824
825/// Why this caller may not amend (`403` with the code).
826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
827pub enum AmendForbidden {
828    /// Only the submitter corrects a result (`not_submitter`).
829    NotSubmitter,
830    /// The reviewer never records the merge: checker and merger stay
831    /// separate (`not_merger`).
832    NotMerger,
833}
834
835impl AmendForbidden {
836    pub fn code(self) -> &'static str {
837        match self {
838            Self::NotSubmitter => "not_submitter",
839            Self::NotMerger => "not_merger",
840        }
841    }
842}
843
844/// Who may amend: the submitter always (a correction, or its merge
845/// record); a merge record also a person or a registered Queen session;
846/// never the reviewer (`not_merger`), anyone else `not_submitter`.
847pub fn may_amend(request: &TaskAmendRequestV1, caller: AmendCaller) -> Result<(), AmendForbidden> {
848    if caller.submitter {
849        return Ok(());
850    }
851    if request.merge_commit.is_none() {
852        return Err(AmendForbidden::NotSubmitter);
853    }
854    if caller.reviewer {
855        return Err(AmendForbidden::NotMerger);
856    }
857    if caller.person || caller.queen {
858        Ok(())
859    } else {
860        Err(AmendForbidden::NotSubmitter)
861    }
862}
863
864/// 3.12.3: a `done` task's merge record (`amend` with `merge_commit`).
865#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
866pub struct TaskMergeV1 {
867    pub commit: String,
868    pub by: TaskActorV1,
869    pub at: UtcTimestamp,
870}
871
872/// What a task update is (C0 2.2): the holder's progress, or a verifier's
873/// verdict.
874#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
875#[serde(rename_all = "snake_case")]
876pub enum TaskUpdateKind {
877    Progress,
878    Verification,
879    /// 3.12.3: the submitter amended its result (`amended_from` holds the
880    /// evidence before).
881    Amendment,
882    #[serde(other)]
883    Unknown,
884}
885
886/// A task's priority (C0 2.2); absent means `normal`.
887#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
888#[serde(rename_all = "snake_case")]
889pub enum TaskPriority {
890    High,
891    #[default]
892    Normal,
893}
894
895/// `PATCH …/{number}` (C0 2.2 E47, `tasks.propose`): an agent sets a
896/// task's priority and labels. `base_version` is the task's
897/// `fields_version`; a mismatch answers `409 stale_base`.
898#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
899#[serde(deny_unknown_fields)]
900pub struct TaskFieldsRequestV1 {
901    pub schema: String,
902    pub base_version: u32,
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub priority: Option<TaskPriority>,
905    #[serde(default, skip_serializing_if = "Option::is_none")]
906    pub labels: Option<Vec<String>>,
907}
908
909impl TaskFieldsRequestV1 {
910    pub fn validate(&self) -> Result<(), ValidationError> {
911        require_schema(&self.schema, TASK_FIELDS_SCHEMA)?;
912        if self.priority.is_none() && self.labels.is_none() {
913            return Err(ValidationError::new("set priority, labels or both"));
914        }
915        if let Some(labels) = &self.labels {
916            validate_labels(labels)?;
917        }
918        Ok(())
919    }
920}
921
922/// At most [`MAX_TASK_LABELS`] unique labels of `^[a-z0-9-]{1,32}$`.
923pub fn validate_labels(labels: &[String]) -> Result<(), ValidationError> {
924    if labels.len() > MAX_TASK_LABELS {
925        return Err(ValidationError::new("at most 16 labels"));
926    }
927    let mut seen = std::collections::BTreeSet::new();
928    for label in labels {
929        let ok = (1..=32).contains(&label.len())
930            && label
931                .bytes()
932                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
933        if !ok {
934            return Err(ValidationError::new("a label is 1-32 of [a-z0-9-]"));
935        }
936        if !seen.insert(label) {
937            return Err(ValidationError::new("labels are unique"));
938        }
939    }
940    Ok(())
941}
942
943/// One update as a task view shows it.
944#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
945pub struct TaskUpdateV1 {
946    pub by: TaskActorV1,
947    pub at: UtcTimestamp,
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub summary: Option<String>,
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    pub usage: Option<UsageV1>,
952    /// C0 2.2; absent from older Hives (progress).
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub kind: Option<TaskUpdateKind>,
955    /// C0 2.2: a verifier's verdict, on `kind: verification`.
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub verdict: Option<TaskVerdict>,
958    /// 3.12.3, on `kind: amendment`: the result's evidence before the
959    /// amendment, so the history keeps every earlier reference.
960    #[serde(default, skip_serializing_if = "Vec::is_empty")]
961    pub amended_from: Vec<EvidenceViewV1>,
962    /// 3.12.3, on `kind: amendment`: the evidence after it (old → new), or
963    /// the merge commit of a merge record.
964    #[serde(default, skip_serializing_if = "Vec::is_empty")]
965    pub amended_to: Vec<EvidenceViewV1>,
966}
967
968/// Evidence as Hive shows it: `verified` is set by Hive (a `receipt`
969/// whose digest and signature it checked), never by a client.
970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971pub struct EvidenceViewV1 {
972    pub kind: EvidenceKind,
973    #[serde(default, skip_serializing_if = "Option::is_none")]
974    pub reference: Option<String>,
975    #[serde(default, skip_serializing_if = "Option::is_none")]
976    pub anchor: Option<CodeAnchorV1>,
977    pub verified: bool,
978}
979
980/// The submitted result, as a task view shows it.
981#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
982pub struct TaskResultV1 {
983    pub by: TaskActorV1,
984    pub at: UtcTimestamp,
985    /// The proposal that carries it; 0 when there is none (Hive v5: the
986    /// result completed the task directly).
987    pub proposal: u64,
988    /// Hive v5: the log seq of the result's appended knowledge.
989    #[serde(default, skip_serializing_if = "Option::is_none")]
990    pub seq: Option<u64>,
991    #[serde(default, skip_serializing_if = "Option::is_none")]
992    pub summary: Option<String>,
993    #[serde(default, skip_serializing_if = "Vec::is_empty")]
994    pub evidence: Vec<EvidenceViewV1>,
995    #[serde(default, skip_serializing_if = "Option::is_none")]
996    pub usage: Option<UsageV1>,
997    /// 3.12.3: the merge, once recorded on a `done` task.
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub merge: Option<TaskMergeV1>,
1000}
1001
1002/// Who decided on a result: a person, or the auto-merge threshold (Personal).
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1004#[serde(tag = "kind", rename_all = "snake_case")]
1005pub enum DecidedByV1 {
1006    Person {
1007        label: String,
1008    },
1009    Threshold,
1010    #[serde(other)]
1011    Unknown,
1012}
1013
1014#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1015#[serde(rename_all = "snake_case")]
1016pub enum TaskDecisionOutcome {
1017    Accepted,
1018    Declined,
1019    #[serde(other)]
1020    Unknown,
1021}
1022
1023/// The decision on a result.
1024#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1025pub struct TaskDecisionV1 {
1026    pub outcome: TaskDecisionOutcome,
1027    pub decided_by: DecidedByV1,
1028    pub at: UtcTimestamp,
1029    #[serde(default, skip_serializing_if = "Option::is_none")]
1030    pub reason: Option<String>,
1031}
1032
1033/// `GET /v1/vaults/{vault_id}/tasks/{number}`.
1034#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1035pub struct TaskV1 {
1036    pub schema: String,
1037    pub vault_id: String,
1038    pub number: u64,
1039    pub state: TaskState,
1040    /// Hive v5 lifecycle; absent when Hive sends none.
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub lifecycle: Option<TaskLifecycle>,
1043    /// The task's title; absent when Hive sends none (shown as untitled).
1044    #[serde(default, skip_serializing_if = "Option::is_none")]
1045    pub title: Option<String>,
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub goal: Option<String>,
1048    #[serde(default, skip_serializing_if = "Option::is_none")]
1049    pub acceptance: Option<String>,
1050    #[serde(default, skip_serializing_if = "Option::is_none")]
1051    pub role: Option<String>,
1052    pub origin: TaskOriginV1,
1053    pub created_by: TaskActorV1,
1054    pub created_at: UtcTimestamp,
1055    /// The person who approved a proposed task.
1056    #[serde(default, skip_serializing_if = "Option::is_none")]
1057    pub approved_by: Option<TaskActorV1>,
1058    #[serde(default, skip_serializing_if = "Option::is_none")]
1059    pub claim: Option<TaskClaimV1>,
1060    /// Newest last, at most [`MAX_VIEW_UPDATES`].
1061    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1062    pub updates: Vec<TaskUpdateV1>,
1063    #[serde(default, skip_serializing_if = "Option::is_none")]
1064    pub result: Option<TaskResultV1>,
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub decision: Option<TaskDecisionV1>,
1067    /// C0 2.2: the agent whose `accept` made the task `done`.
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub verified_by: Option<TaskActorV1>,
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub verified_at: Option<UtcTimestamp>,
1072    /// C0 2.2: the last verdict (accept or reject).
1073    #[serde(default, skip_serializing_if = "Option::is_none")]
1074    pub verification: Option<TaskVerificationV1>,
1075    /// C0 2.2; absent means `normal`.
1076    #[serde(default, skip_serializing_if = "Option::is_none")]
1077    pub priority: Option<TaskPriority>,
1078    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1079    pub labels: Vec<String>,
1080    /// C0 2.2: +1 whenever priority or labels change (the `base_version`
1081    /// of [`TaskFieldsRequestV1`]).
1082    #[serde(default, skip_serializing_if = "Option::is_none")]
1083    pub fields_version: Option<u32>,
1084    /// Process v1: the task's process part (risk, what G6 needs, Hive's G6
1085    /// record). Absent on a task that is not a process task.
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub process: Option<super::gate::TaskProcessV1>,
1088}
1089
1090impl TaskV1 {
1091    pub fn validate(&self) -> Result<(), ValidationError> {
1092        require_schema(&self.schema, TASK_SCHEMA)?;
1093        super::validate_vault_id(&self.vault_id)?;
1094        if self.number == 0 {
1095            return Err(ValidationError::new("task numbers start at 1"));
1096        }
1097        if (self.state == TaskState::Claimed) != self.claim.is_some() {
1098            return Err(ValidationError::new(
1099                "a task carries a claim exactly while it is claimed",
1100            ));
1101        }
1102        if self.updates.len() > MAX_VIEW_UPDATES {
1103            return Err(ValidationError::new("too many updates in a task view"));
1104        }
1105        if let Some(approver) = &self.approved_by
1106            && approver.kind != TaskActorKind::Person
1107        {
1108            return Err(ValidationError::new("only a person approves a task"));
1109        }
1110        if matches!(self.state, TaskState::Submitted | TaskState::Done) && self.result.is_none() {
1111            return Err(ValidationError::new(
1112                "a submitted or done task carries its result",
1113            ));
1114        }
1115        Ok(())
1116    }
1117}
1118
1119/// One task in a listing.
1120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1121pub struct TaskSummaryV1 {
1122    pub number: u64,
1123    pub state: TaskState,
1124    /// Hive v5 lifecycle (additive; absent from a 1.x Hive).
1125    #[serde(default, skip_serializing_if = "Option::is_none")]
1126    pub lifecycle: Option<TaskLifecycle>,
1127    #[serde(default, skip_serializing_if = "Option::is_none")]
1128    pub title: Option<String>,
1129    #[serde(default, skip_serializing_if = "Option::is_none")]
1130    pub role: Option<String>,
1131    pub origin: TaskOriginKind,
1132    pub updated_at: UtcTimestamp,
1133    /// Whether this instance holds its claim.
1134    pub mine: bool,
1135    /// C0 2.2; absent means `normal`.
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub priority: Option<TaskPriority>,
1138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1139    pub labels: Vec<String>,
1140    #[serde(default, skip_serializing_if = "Option::is_none")]
1141    pub fields_version: Option<u32>,
1142}
1143
1144/// `GET /v1/vaults/{vault_id}/tasks`.
1145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1146pub struct TaskListV1 {
1147    pub schema: String,
1148    pub vault_id: String,
1149    pub tasks: Vec<TaskSummaryV1>,
1150}
1151
1152impl TaskListV1 {
1153    pub fn validate(&self) -> Result<(), ValidationError> {
1154        require_schema(&self.schema, TASK_LIST_SCHEMA)?;
1155        super::validate_vault_id(&self.vault_id)?;
1156        if self.tasks.len() > MAX_TASKS_PAGE {
1157            return Err(ValidationError::new("too many tasks in one page"));
1158        }
1159        Ok(())
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    use super::*;
1166
1167    fn propose() -> TaskProposeRequestV1 {
1168        TaskProposeRequestV1 {
1169            schema: TASK_PROPOSE_SCHEMA.into(),
1170            title: Some("Explain the retry budget".into()),
1171            goal: None,
1172            acceptance: Some("A fact with an anchor on retry.rs".into()),
1173            role: None,
1174            origin: TaskOriginV1 {
1175                kind: TaskOriginKind::Agent,
1176                facts: Vec::new(),
1177                message: None,
1178            },
1179            session_ref: "s-1".into(),
1180        }
1181    }
1182
1183    fn claim() -> ClaimRefV1 {
1184        ClaimRefV1 {
1185            lease_id: "lease-1".into(),
1186            fencing_token: 3,
1187        }
1188    }
1189
1190    #[test]
1191    fn an_unknown_task_origin_parses_as_unknown() {
1192        let origin: TaskOriginV1 = serde_json::from_value(serde_json::json!({
1193            "kind": "quarantine_timeout", "facts": ["f-1"],
1194        }))
1195        .unwrap();
1196        assert_eq!(origin.kind, TaskOriginKind::Unknown);
1197    }
1198
1199    #[test]
1200    fn engines_propose_only_agent_or_contradiction_tasks() {
1201        assert!(propose().validate().is_ok());
1202        let mut person = propose();
1203        person.origin.kind = TaskOriginKind::Person;
1204        assert!(person.validate().is_err());
1205        let mut contradiction = propose();
1206        contradiction.origin = TaskOriginV1 {
1207            kind: TaskOriginKind::Contradiction,
1208            facts: vec!["f-1".into(), "f-1".into()],
1209            message: None,
1210        };
1211        assert!(contradiction.validate().is_err());
1212        contradiction.origin.facts[1] = "f-2".into();
1213        assert!(contradiction.validate().is_ok());
1214    }
1215
1216    #[test]
1217    fn a_result_needs_evidence_that_fits_its_kind() {
1218        let mut result = TaskResultRequestV1 {
1219            schema: TASK_RESULT_SCHEMA.into(),
1220            claim: claim(),
1221            summary: Some("Done".into()),
1222            evidence: Vec::new(),
1223            draft: None,
1224            usage: None,
1225            outcome: None,
1226            gate_record: None,
1227        };
1228        assert!(result.validate().is_err(), "no evidence");
1229        let r = |kind, reference: &str| EvidenceV1 {
1230            kind,
1231            reference: Some(reference.into()),
1232            anchor: None,
1233        };
1234        for (evidence, ok) in [
1235            (r(EvidenceKind::Commit, "9f01cf32a3"), true),
1236            (r(EvidenceKind::Commit, "main"), false),
1237            (r(EvidenceKind::Url, "http://example.com"), false),
1238            (r(EvidenceKind::Receipt, &"a".repeat(64)), true),
1239            (r(EvidenceKind::FileAnchor, "src/retry.rs"), false),
1240        ] {
1241            result.evidence = vec![evidence.clone()];
1242            assert_eq!(result.validate().is_ok(), ok, "{evidence:?}");
1243        }
1244        result.claim.fencing_token = 0;
1245        result.evidence = vec![r(EvidenceKind::Commit, "9f01cf32a3")];
1246        assert!(result.validate().is_err(), "fencing tokens start at 1");
1247    }
1248
1249    #[test]
1250    fn summaries_are_one_line() {
1251        let update = |summary: &str| TaskUpdateRequestV1 {
1252            schema: TASK_UPDATE_SCHEMA.into(),
1253            claim: claim(),
1254            summary: Some(summary.into()),
1255            usage: None,
1256        };
1257        assert!(update("Reproduced the timeout").validate().is_ok());
1258        assert!(update("line one\nline two").validate().is_err());
1259        assert!(
1260            update(&"x".repeat(MAX_SUMMARY_CHARS + 1))
1261                .validate()
1262                .is_err()
1263        );
1264    }
1265
1266    #[test]
1267    fn decided_by_is_a_person_or_the_threshold() {
1268        let person: DecidedByV1 =
1269            serde_json::from_value(serde_json::json!({"kind": "person", "label": "Ada"})).unwrap();
1270        assert_eq!(
1271            person,
1272            DecidedByV1::Person {
1273                label: "Ada".into()
1274            }
1275        );
1276        let threshold: DecidedByV1 =
1277            serde_json::from_value(serde_json::json!({"kind": "threshold"})).unwrap();
1278        assert_eq!(threshold, DecidedByV1::Threshold);
1279    }
1280
1281    fn fixture(name: &str) -> serde_json::Value {
1282        let path = format!("{}/tests/fixtures/hive/{name}", env!("CARGO_MANIFEST_DIR"));
1283        serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
1284    }
1285
1286    /// Hive v5: `outcome` is sent only when set (a 1.x Hive denies the
1287    /// field), and a failed result brings no draft.
1288    #[test]
1289    fn a_failed_result_names_its_outcome_and_brings_no_draft() {
1290        let mut result: TaskResultRequestV1 =
1291            serde_json::from_value(fixture("task_result.request.json")).unwrap();
1292        assert_eq!(result.outcome, None);
1293        assert!(
1294            serde_json::to_value(&result)
1295                .unwrap()
1296                .get("outcome")
1297                .is_none(),
1298            "a completed result sends no outcome"
1299        );
1300        result.outcome = Some(TaskOutcome::Failed);
1301        assert!(result.validate().is_ok());
1302        assert_eq!(serde_json::to_value(&result).unwrap()["outcome"], "failed");
1303        result.draft =
1304            Some(serde_json::from_value(fixture("proposal.import.request.json")).unwrap());
1305        assert_eq!(
1306            result.validate().unwrap_err().to_string(),
1307            "a failed result brings no draft"
1308        );
1309        result.outcome = Some(TaskOutcome::Completed);
1310        assert!(
1311            result.validate().is_ok(),
1312            "a completed result may bring one"
1313        );
1314    }
1315
1316    /// A 1.x view (`submitted`, no `lifecycle`, a proposal) and a v5 view
1317    /// (`failed`, `lifecycle`, `seq` with proposal 0) both parse; unknown
1318    /// lifecycle words stay tolerated.
1319    #[test]
1320    fn task_views_of_both_lines_parse() {
1321        let old: TaskV1 = serde_json::from_value(fixture("task.done.response.json")).unwrap();
1322        assert_eq!(old.lifecycle, None);
1323        assert_eq!(old.result.as_ref().unwrap().seq, None);
1324        assert!(
1325            serde_json::to_value(&old)
1326                .unwrap()
1327                .get("lifecycle")
1328                .is_none()
1329        );
1330
1331        let mut v5 = fixture("task.done.response.json");
1332        v5["state"] = "failed".into();
1333        v5["lifecycle"] = "failed".into();
1334        v5["result"]["proposal"] = 0.into();
1335        v5["result"]["seq"] = 42.into();
1336        let v5: TaskV1 = serde_json::from_value(v5).unwrap();
1337        assert_eq!(v5.state, TaskState::Failed);
1338        assert_eq!(v5.lifecycle, Some(TaskLifecycle::Failed));
1339        let result = v5.result.as_ref().unwrap();
1340        assert_eq!((result.proposal, result.seq), (0, Some(42)));
1341        assert!(v5.validate().is_ok());
1342
1343        let mut list = fixture("task_list.response.json");
1344        list["tasks"][0]["lifecycle"] = "paused_someday".into();
1345        let list: TaskListV1 = serde_json::from_value(list).unwrap();
1346        assert_eq!(list.tasks[0].lifecycle, Some(TaskLifecycle::Unknown));
1347        let state: TaskState = serde_json::from_value("someday".into()).unwrap();
1348        assert_eq!(state, TaskState::Unknown);
1349    }
1350
1351    fn v2_route(name: &str) -> serde_json::Value {
1352        let path = format!(
1353            "{}/tests/fixtures/hive-v2/routes/{name}.json",
1354            env!("CARGO_MANIFEST_DIR")
1355        );
1356        serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1357    }
1358
1359    /// C0 2.2 (E48, E47 fields): the contract's requests validate, its
1360    /// answers parse, and a reject without reason or an empty field change
1361    /// is refused before it is sent.
1362    #[test]
1363    fn the_c0_2_2_task_shapes_hold() {
1364        for name in ["task_verify.request", "task_verify.reject.request"] {
1365            let request: TaskVerifyRequestV1 = serde_json::from_value(v2_route(name)).unwrap();
1366            request.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
1367            assert_eq!(serde_json::to_value(&request).unwrap(), v2_route(name));
1368        }
1369        let mut reject: TaskVerifyRequestV1 =
1370            serde_json::from_value(v2_route("task_verify.reject.request")).unwrap();
1371        reject.reason = None;
1372        assert_eq!(
1373            reject.validate().unwrap_err().to_string(),
1374            "a reject names its reason"
1375        );
1376        for (name, state) in [
1377            ("task_verify.response", TaskState::Done),
1378            ("task_verify.reject.response", TaskState::Open),
1379            ("task_result.submitted.response", TaskState::Submitted),
1380        ] {
1381            let ack: TaskAckV1 = serde_json::from_value(v2_route(name)).unwrap();
1382            assert_eq!(ack.state, state, "{name}");
1383        }
1384        let fields: TaskFieldsRequestV1 =
1385            serde_json::from_value(v2_route("task_fields.request")).unwrap();
1386        fields.validate().unwrap();
1387        assert_eq!(fields.priority, Some(TaskPriority::High));
1388        let empty = TaskFieldsRequestV1 {
1389            labels: None,
1390            priority: None,
1391            ..fields.clone()
1392        };
1393        assert!(empty.validate().is_err());
1394        let bad = TaskFieldsRequestV1 {
1395            labels: Some(vec!["Docs".into()]),
1396            ..fields
1397        };
1398        assert!(bad.validate().is_err());
1399    }
1400}