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`) | | [`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/// C0 2.2 E47 `PATCH`: a task's priority and labels.
103pub const TASK_FIELDS_SCHEMA: &str = "leanctx.hive.task-fields/v1";
104
105pub const MIN_TITLE_CHARS: usize = 5;
106pub const MAX_TITLE_CHARS: usize = 180;
107pub const MAX_GOAL_CHARS: usize = 2000;
108pub const MAX_ACCEPTANCE_CHARS: usize = 2000;
109/// Updates and result summaries are summaries, not prompts: one line.
110pub const MAX_SUMMARY_CHARS: usize = 500;
111pub const MAX_REASON_CHARS: usize = 280;
112/// A verifier's reason (C0 2.2 E48), required on `reject`.
113pub const MAX_VERIFY_REASON_CHARS: usize = 2000;
114pub const MAX_TASK_LABELS: usize = 16;
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    /// C0 2.2: a result waits for a second agent's check.
155    Submitted,
156    Completed,
157    Failed,
158    Cancelled,
159    #[serde(other)]
160    Unknown,
161}
162
163/// How the holder ends its claim with a result (Hive v5); absent on the
164/// wire means `completed`.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum TaskOutcome {
168    #[default]
169    Completed,
170    Failed,
171}
172
173/// Who created a task, and why.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum TaskOriginKind {
177    /// A person, on the web.
178    Person,
179    /// An engine, for its agent.
180    Agent,
181    /// An engine that found two facts contradicting each other.
182    Contradiction,
183    /// A question no agent answered (section 2).
184    Question,
185    /// Any other origin.
186    #[serde(other)]
187    Unknown,
188}
189
190/// Origin of a task. `facts` names exactly two distinct fact ids for a
191/// contradiction; `message` names the question's message id for a
192/// 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/// A task write carries its text (`plain`).
371fn text_required(plain: bool) -> Result<(), ValidationError> {
372    if plain {
373        Ok(())
374    } else {
375        Err(ValidationError::new("text is required"))
376    }
377}
378
379/// The claim a write acts under: the lease id and its fencing token.
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(deny_unknown_fields)]
382pub struct ClaimRefV1 {
383    pub lease_id: String,
384    pub fencing_token: u64,
385}
386
387impl ClaimRefV1 {
388    pub fn validate(&self) -> Result<(), ValidationError> {
389        validate_id(&self.lease_id, "lease_id")?;
390        if self.fencing_token == 0 {
391            return Err(ValidationError::new("fencing tokens start at 1"));
392        }
393        Ok(())
394    }
395}
396
397fn validate_minutes(minutes: u32) -> Result<(), ValidationError> {
398    if (MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
399        Ok(())
400    } else {
401        Err(ValidationError::new(format!(
402            "minutes must be {MIN_CLAIM_MINUTES}-{MAX_CLAIM_MINUTES}"
403        )))
404    }
405}
406
407/// `POST /v1/vaults/{vault_id}/tasks`: an engine proposes a task.
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(deny_unknown_fields)]
410pub struct TaskProposeRequestV1 {
411    pub schema: String,
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub title: Option<String>,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub goal: Option<String>,
416    /// When the task is done; required in the clear.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub acceptance: Option<String>,
419    /// The agent role it is for (section 5); any role when absent.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub role: Option<String>,
422    pub origin: TaskOriginV1,
423    /// Opaque, per-session id (no host or user data).
424    pub session_ref: String,
425}
426
427impl TaskProposeRequestV1 {
428    pub fn validate(&self) -> Result<(), ValidationError> {
429        require_schema(&self.schema, TASK_PROPOSE_SCHEMA)?;
430        self.origin.validate_from_engine()?;
431        validate_id(&self.session_ref, "session_ref")?;
432        if let Some(role) = &self.role {
433            validate_role(role)?;
434        }
435        let plain = self.title.is_some() || self.goal.is_some() || self.acceptance.is_some();
436        text_required(plain)?;
437        if plain {
438            validate_title(self.title.as_deref().unwrap_or_default())?;
439            if let Some(goal) = &self.goal {
440                validate_text(goal, MAX_GOAL_CHARS, "goal", true)?;
441            }
442            validate_text(
443                self.acceptance.as_deref().unwrap_or_default(),
444                MAX_ACCEPTANCE_CHARS,
445                "acceptance",
446                true,
447            )?;
448        }
449        Ok(())
450    }
451}
452
453/// `POST …/{number}/claim`.
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(deny_unknown_fields)]
456pub struct TaskClaimRequestV1 {
457    pub schema: String,
458    /// The bus agent id the claim is for.
459    pub agent: String,
460    pub session_ref: String,
461    pub minutes: u32,
462    /// The engine opt-in the claim works under (section 4), whose budget
463    /// it draws on: the `optin_id` the engine last read. Hive requires it
464    /// and refuses a stale one (`optin_changed`).
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub optin_id: Option<String>,
467}
468
469impl TaskClaimRequestV1 {
470    pub fn validate(&self) -> Result<(), ValidationError> {
471        require_schema(&self.schema, TASK_CLAIM_REQUEST_SCHEMA)?;
472        validate_id(&self.agent, "agent")?;
473        validate_id(&self.session_ref, "session_ref")?;
474        if let Some(optin) = &self.optin_id {
475            validate_id(optin, "optin_id")?;
476        }
477        validate_minutes(self.minutes)
478    }
479}
480
481/// `POST …/{number}/claim/renew`. 1.9 erratum: the renewed claim keeps its
482/// lease id and fencing token (the bus lease rule).
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(deny_unknown_fields)]
485pub struct TaskClaimRenewRequestV1 {
486    pub schema: String,
487    pub claim: ClaimRefV1,
488    pub minutes: u32,
489}
490
491impl TaskClaimRenewRequestV1 {
492    pub fn validate(&self) -> Result<(), ValidationError> {
493        require_schema(&self.schema, TASK_CLAIM_RENEW_SCHEMA)?;
494        self.claim.validate()?;
495        validate_minutes(self.minutes)
496    }
497}
498
499/// The one claim on a task, as a task view shows it.
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501pub struct TaskClaimV1 {
502    pub holder: TaskActorV1,
503    pub since: UtcTimestamp,
504    pub until: UtcTimestamp,
505}
506
507/// Answer of claim and renew: the lease the holder writes under.
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509pub struct TaskClaimAckV1 {
510    pub schema: String,
511    pub number: u64,
512    pub claim: ClaimRefV1,
513    pub until: UtcTimestamp,
514}
515
516/// `POST …/{number}/release`: give the task back (blocked, out of scope).
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518#[serde(deny_unknown_fields)]
519pub struct TaskReleaseRequestV1 {
520    pub schema: String,
521    pub claim: ClaimRefV1,
522    /// Why, in one line.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub reason: Option<String>,
525}
526
527impl TaskReleaseRequestV1 {
528    pub fn validate(&self) -> Result<(), ValidationError> {
529        require_schema(&self.schema, TASK_RELEASE_SCHEMA)?;
530        self.claim.validate()?;
531        if let Some(reason) = &self.reason {
532            validate_text(reason, MAX_REASON_CHARS, "reason", false)?;
533        }
534        Ok(())
535    }
536}
537
538/// What the work cost the agent's owner so far (self-reported): model
539/// tokens and, if known, US dollars in millionths (integers only, as
540/// everything under a signature).
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(deny_unknown_fields)]
543pub struct UsageV1 {
544    pub tokens: u64,
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub usd_micros: Option<u64>,
547}
548
549/// `POST …/{number}/updates`: one short summary from the holder.
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(deny_unknown_fields)]
552pub struct TaskUpdateRequestV1 {
553    pub schema: String,
554    pub claim: ClaimRefV1,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub summary: Option<String>,
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub usage: Option<UsageV1>,
559}
560
561impl TaskUpdateRequestV1 {
562    pub fn validate(&self) -> Result<(), ValidationError> {
563        require_schema(&self.schema, TASK_UPDATE_SCHEMA)?;
564        self.claim.validate()?;
565        text_required(self.summary.is_some())?;
566        if let Some(summary) = &self.summary {
567            validate_text(summary, MAX_SUMMARY_CHARS, "summary", false)?;
568        }
569        Ok(())
570    }
571}
572
573/// `POST …/{number}/result`: the holder's result. It ends the claim; a 1.x
574/// Hive turns it into a proposal (`origin task`), Hive v5 ends the task
575/// (`done`, or `failed` with `outcome: failed`). `draft` is its knowledge
576/// change, if it brings one, exactly as a regular draft; a failed result
577/// brings none.
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(deny_unknown_fields)]
580pub struct TaskResultRequestV1 {
581    pub schema: String,
582    pub claim: ClaimRefV1,
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub summary: Option<String>,
585    #[serde(default, skip_serializing_if = "Vec::is_empty")]
586    pub evidence: Vec<EvidenceV1>,
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub draft: Option<DraftProposalV1>,
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub usage: Option<UsageV1>,
591    /// Hive v5; absent = `completed`. A 1.x Hive refuses the field
592    /// (unknown fields are denied), so it is sent only to a v5 Hive.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub outcome: Option<TaskOutcome>,
595}
596
597impl TaskResultRequestV1 {
598    pub fn validate(&self) -> Result<(), ValidationError> {
599        require_schema(&self.schema, TASK_RESULT_SCHEMA)?;
600        self.claim.validate()?;
601        let plain = self.summary.is_some() || !self.evidence.is_empty();
602        text_required(plain)?;
603        if plain {
604            validate_text(
605                self.summary.as_deref().unwrap_or_default(),
606                MAX_SUMMARY_CHARS,
607                "summary",
608                false,
609            )?;
610            validate_evidence(&self.evidence)?;
611        }
612        if let Some(draft) = &self.draft {
613            if self.outcome == Some(TaskOutcome::Failed) {
614                return Err(ValidationError::new("a failed result brings no draft"));
615            }
616            draft.validate()?;
617        }
618        Ok(())
619    }
620}
621
622/// Answer of every task write: the number and the state afterwards.
623#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
624pub struct TaskAckV1 {
625    pub schema: String,
626    pub number: u64,
627    pub state: TaskState,
628}
629
630/// A verifier's verdict on a submitted result (C0 2.2): `accept` makes the
631/// task `done`, `reject` opens it again.
632#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
633#[serde(rename_all = "snake_case")]
634pub enum TaskVerdict {
635    Accept,
636    Reject,
637    #[serde(other)]
638    Unknown,
639}
640
641/// `POST …/{number}/verify` (C0 2.2 E48, `tasks.work`): another agent's
642/// verdict. `worker_id` is the verifying agent, never the result's
643/// (`403 self_verify`); `reason` is required on `reject`.
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
645#[serde(deny_unknown_fields)]
646pub struct TaskVerifyRequestV1 {
647    pub schema: String,
648    pub worker_id: String,
649    pub verdict: TaskVerdict,
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub reason: Option<String>,
652}
653
654impl TaskVerifyRequestV1 {
655    pub fn validate(&self) -> Result<(), ValidationError> {
656        require_schema(&self.schema, TASK_VERIFY_SCHEMA)?;
657        let worker_ok = (1..=128).contains(&self.worker_id.len())
658            && self
659                .worker_id
660                .bytes()
661                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'));
662        if !worker_ok {
663            return Err(ValidationError::new(
664                "worker_id is 1-128 of [A-Za-z0-9._:-]",
665            ));
666        }
667        match (self.verdict, &self.reason) {
668            (TaskVerdict::Unknown, _) => Err(ValidationError::new("verdict is accept or reject")),
669            (TaskVerdict::Reject, None) => Err(ValidationError::new("a reject names its reason")),
670            (_, Some(reason)) => validate_text(reason, MAX_VERIFY_REASON_CHARS, "reason", false),
671            (TaskVerdict::Accept, None) => Ok(()),
672        }
673    }
674}
675
676/// The last verdict on a task's result, as a task view shows it (C0 2.2).
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678pub struct TaskVerificationV1 {
679    pub verdict: TaskVerdict,
680    pub by: TaskActorV1,
681    pub at: UtcTimestamp,
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub reason: Option<String>,
684}
685
686/// What a task update is (C0 2.2): the holder's progress, or a verifier's
687/// verdict.
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
689#[serde(rename_all = "snake_case")]
690pub enum TaskUpdateKind {
691    Progress,
692    Verification,
693    #[serde(other)]
694    Unknown,
695}
696
697/// A task's priority (C0 2.2); absent means `normal`.
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum TaskPriority {
701    High,
702    #[default]
703    Normal,
704}
705
706/// `PATCH …/{number}` (C0 2.2 E47, `tasks.propose`): an agent sets a
707/// task's priority and labels. `base_version` is the task's
708/// `fields_version`; a mismatch answers `409 stale_base`.
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710#[serde(deny_unknown_fields)]
711pub struct TaskFieldsRequestV1 {
712    pub schema: String,
713    pub base_version: u32,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub priority: Option<TaskPriority>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub labels: Option<Vec<String>>,
718}
719
720impl TaskFieldsRequestV1 {
721    pub fn validate(&self) -> Result<(), ValidationError> {
722        require_schema(&self.schema, TASK_FIELDS_SCHEMA)?;
723        if self.priority.is_none() && self.labels.is_none() {
724            return Err(ValidationError::new("set priority, labels or both"));
725        }
726        if let Some(labels) = &self.labels {
727            validate_labels(labels)?;
728        }
729        Ok(())
730    }
731}
732
733/// At most [`MAX_TASK_LABELS`] unique labels of `^[a-z0-9-]{1,32}$`.
734pub fn validate_labels(labels: &[String]) -> Result<(), ValidationError> {
735    if labels.len() > MAX_TASK_LABELS {
736        return Err(ValidationError::new("at most 16 labels"));
737    }
738    let mut seen = std::collections::BTreeSet::new();
739    for label in labels {
740        let ok = (1..=32).contains(&label.len())
741            && label
742                .bytes()
743                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
744        if !ok {
745            return Err(ValidationError::new("a label is 1-32 of [a-z0-9-]"));
746        }
747        if !seen.insert(label) {
748            return Err(ValidationError::new("labels are unique"));
749        }
750    }
751    Ok(())
752}
753
754/// One update as a task view shows it.
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756pub struct TaskUpdateV1 {
757    pub by: TaskActorV1,
758    pub at: UtcTimestamp,
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub summary: Option<String>,
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub usage: Option<UsageV1>,
763    /// C0 2.2; absent from older Hives (progress).
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub kind: Option<TaskUpdateKind>,
766    /// C0 2.2: a verifier's verdict, on `kind: verification`.
767    #[serde(default, skip_serializing_if = "Option::is_none")]
768    pub verdict: Option<TaskVerdict>,
769}
770
771/// Evidence as Hive shows it: `verified` is set by Hive (a `receipt`
772/// whose digest and signature it checked), never by a client.
773#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774pub struct EvidenceViewV1 {
775    pub kind: EvidenceKind,
776    #[serde(default, skip_serializing_if = "Option::is_none")]
777    pub reference: Option<String>,
778    #[serde(default, skip_serializing_if = "Option::is_none")]
779    pub anchor: Option<CodeAnchorV1>,
780    pub verified: bool,
781}
782
783/// The submitted result, as a task view shows it.
784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
785pub struct TaskResultV1 {
786    pub by: TaskActorV1,
787    pub at: UtcTimestamp,
788    /// The proposal that carries it; 0 when there is none (Hive v5: the
789    /// result completed the task directly).
790    pub proposal: u64,
791    /// Hive v5: the log seq of the result's appended knowledge.
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub seq: Option<u64>,
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    pub summary: Option<String>,
796    #[serde(default, skip_serializing_if = "Vec::is_empty")]
797    pub evidence: Vec<EvidenceViewV1>,
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub usage: Option<UsageV1>,
800}
801
802/// Who decided on a result: a person, or the auto-merge threshold (Personal).
803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
804#[serde(tag = "kind", rename_all = "snake_case")]
805pub enum DecidedByV1 {
806    Person {
807        label: String,
808    },
809    Threshold,
810    #[serde(other)]
811    Unknown,
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
815#[serde(rename_all = "snake_case")]
816pub enum TaskDecisionOutcome {
817    Accepted,
818    Declined,
819    #[serde(other)]
820    Unknown,
821}
822
823/// The decision on a result.
824#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
825pub struct TaskDecisionV1 {
826    pub outcome: TaskDecisionOutcome,
827    pub decided_by: DecidedByV1,
828    pub at: UtcTimestamp,
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub reason: Option<String>,
831}
832
833/// `GET /v1/vaults/{vault_id}/tasks/{number}`.
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835pub struct TaskV1 {
836    pub schema: String,
837    pub vault_id: String,
838    pub number: u64,
839    pub state: TaskState,
840    /// Hive v5 lifecycle; absent when Hive sends none.
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub lifecycle: Option<TaskLifecycle>,
843    /// The task's title; absent when Hive sends none (shown as untitled).
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub title: Option<String>,
846    #[serde(default, skip_serializing_if = "Option::is_none")]
847    pub goal: Option<String>,
848    #[serde(default, skip_serializing_if = "Option::is_none")]
849    pub acceptance: Option<String>,
850    #[serde(default, skip_serializing_if = "Option::is_none")]
851    pub role: Option<String>,
852    pub origin: TaskOriginV1,
853    pub created_by: TaskActorV1,
854    pub created_at: UtcTimestamp,
855    /// The person who approved a proposed task.
856    #[serde(default, skip_serializing_if = "Option::is_none")]
857    pub approved_by: Option<TaskActorV1>,
858    #[serde(default, skip_serializing_if = "Option::is_none")]
859    pub claim: Option<TaskClaimV1>,
860    /// Newest last, at most [`MAX_VIEW_UPDATES`].
861    #[serde(default, skip_serializing_if = "Vec::is_empty")]
862    pub updates: Vec<TaskUpdateV1>,
863    #[serde(default, skip_serializing_if = "Option::is_none")]
864    pub result: Option<TaskResultV1>,
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub decision: Option<TaskDecisionV1>,
867    /// C0 2.2: the agent whose `accept` made the task `done`.
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub verified_by: Option<TaskActorV1>,
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    pub verified_at: Option<UtcTimestamp>,
872    /// C0 2.2: the last verdict (accept or reject).
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub verification: Option<TaskVerificationV1>,
875    /// C0 2.2; absent means `normal`.
876    #[serde(default, skip_serializing_if = "Option::is_none")]
877    pub priority: Option<TaskPriority>,
878    #[serde(default, skip_serializing_if = "Vec::is_empty")]
879    pub labels: Vec<String>,
880    /// C0 2.2: +1 whenever priority or labels change (the `base_version`
881    /// of [`TaskFieldsRequestV1`]).
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub fields_version: Option<u32>,
884}
885
886impl TaskV1 {
887    pub fn validate(&self) -> Result<(), ValidationError> {
888        require_schema(&self.schema, TASK_SCHEMA)?;
889        super::validate_vault_id(&self.vault_id)?;
890        if self.number == 0 {
891            return Err(ValidationError::new("task numbers start at 1"));
892        }
893        if (self.state == TaskState::Claimed) != self.claim.is_some() {
894            return Err(ValidationError::new(
895                "a task carries a claim exactly while it is claimed",
896            ));
897        }
898        if self.updates.len() > MAX_VIEW_UPDATES {
899            return Err(ValidationError::new("too many updates in a task view"));
900        }
901        if let Some(approver) = &self.approved_by
902            && approver.kind != TaskActorKind::Person
903        {
904            return Err(ValidationError::new("only a person approves a task"));
905        }
906        if matches!(self.state, TaskState::Submitted | TaskState::Done) && self.result.is_none() {
907            return Err(ValidationError::new(
908                "a submitted or done task carries its result",
909            ));
910        }
911        Ok(())
912    }
913}
914
915/// One task in a listing.
916#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
917pub struct TaskSummaryV1 {
918    pub number: u64,
919    pub state: TaskState,
920    /// Hive v5 lifecycle (additive; absent from a 1.x Hive).
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub lifecycle: Option<TaskLifecycle>,
923    #[serde(default, skip_serializing_if = "Option::is_none")]
924    pub title: Option<String>,
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub role: Option<String>,
927    pub origin: TaskOriginKind,
928    pub updated_at: UtcTimestamp,
929    /// Whether this instance holds its claim.
930    pub mine: bool,
931    /// C0 2.2; absent means `normal`.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub priority: Option<TaskPriority>,
934    #[serde(default, skip_serializing_if = "Vec::is_empty")]
935    pub labels: Vec<String>,
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub fields_version: Option<u32>,
938}
939
940/// `GET /v1/vaults/{vault_id}/tasks`.
941#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
942pub struct TaskListV1 {
943    pub schema: String,
944    pub vault_id: String,
945    pub tasks: Vec<TaskSummaryV1>,
946}
947
948impl TaskListV1 {
949    pub fn validate(&self) -> Result<(), ValidationError> {
950        require_schema(&self.schema, TASK_LIST_SCHEMA)?;
951        super::validate_vault_id(&self.vault_id)?;
952        if self.tasks.len() > MAX_TASKS_PAGE {
953            return Err(ValidationError::new("too many tasks in one page"));
954        }
955        Ok(())
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    fn propose() -> TaskProposeRequestV1 {
964        TaskProposeRequestV1 {
965            schema: TASK_PROPOSE_SCHEMA.into(),
966            title: Some("Explain the retry budget".into()),
967            goal: None,
968            acceptance: Some("A fact with an anchor on retry.rs".into()),
969            role: None,
970            origin: TaskOriginV1 {
971                kind: TaskOriginKind::Agent,
972                facts: Vec::new(),
973                message: None,
974            },
975            session_ref: "s-1".into(),
976        }
977    }
978
979    fn claim() -> ClaimRefV1 {
980        ClaimRefV1 {
981            lease_id: "lease-1".into(),
982            fencing_token: 3,
983        }
984    }
985
986    #[test]
987    fn an_unknown_task_origin_parses_as_unknown() {
988        let origin: TaskOriginV1 = serde_json::from_value(serde_json::json!({
989            "kind": "quarantine_timeout", "facts": ["f-1"],
990        }))
991        .unwrap();
992        assert_eq!(origin.kind, TaskOriginKind::Unknown);
993    }
994
995    #[test]
996    fn engines_propose_only_agent_or_contradiction_tasks() {
997        assert!(propose().validate().is_ok());
998        let mut person = propose();
999        person.origin.kind = TaskOriginKind::Person;
1000        assert!(person.validate().is_err());
1001        let mut contradiction = propose();
1002        contradiction.origin = TaskOriginV1 {
1003            kind: TaskOriginKind::Contradiction,
1004            facts: vec!["f-1".into(), "f-1".into()],
1005            message: None,
1006        };
1007        assert!(contradiction.validate().is_err());
1008        contradiction.origin.facts[1] = "f-2".into();
1009        assert!(contradiction.validate().is_ok());
1010    }
1011
1012    #[test]
1013    fn a_result_needs_evidence_that_fits_its_kind() {
1014        let mut result = TaskResultRequestV1 {
1015            schema: TASK_RESULT_SCHEMA.into(),
1016            claim: claim(),
1017            summary: Some("Done".into()),
1018            evidence: Vec::new(),
1019            draft: None,
1020            usage: None,
1021            outcome: None,
1022        };
1023        assert!(result.validate().is_err(), "no evidence");
1024        let r = |kind, reference: &str| EvidenceV1 {
1025            kind,
1026            reference: Some(reference.into()),
1027            anchor: None,
1028        };
1029        for (evidence, ok) in [
1030            (r(EvidenceKind::Commit, "9f01cf32a3"), true),
1031            (r(EvidenceKind::Commit, "main"), false),
1032            (r(EvidenceKind::Url, "http://example.com"), false),
1033            (r(EvidenceKind::Receipt, &"a".repeat(64)), true),
1034            (r(EvidenceKind::FileAnchor, "src/retry.rs"), false),
1035        ] {
1036            result.evidence = vec![evidence.clone()];
1037            assert_eq!(result.validate().is_ok(), ok, "{evidence:?}");
1038        }
1039        result.claim.fencing_token = 0;
1040        result.evidence = vec![r(EvidenceKind::Commit, "9f01cf32a3")];
1041        assert!(result.validate().is_err(), "fencing tokens start at 1");
1042    }
1043
1044    #[test]
1045    fn summaries_are_one_line() {
1046        let update = |summary: &str| TaskUpdateRequestV1 {
1047            schema: TASK_UPDATE_SCHEMA.into(),
1048            claim: claim(),
1049            summary: Some(summary.into()),
1050            usage: None,
1051        };
1052        assert!(update("Reproduced the timeout").validate().is_ok());
1053        assert!(update("line one\nline two").validate().is_err());
1054        assert!(
1055            update(&"x".repeat(MAX_SUMMARY_CHARS + 1))
1056                .validate()
1057                .is_err()
1058        );
1059    }
1060
1061    #[test]
1062    fn decided_by_is_a_person_or_the_threshold() {
1063        let person: DecidedByV1 =
1064            serde_json::from_value(serde_json::json!({"kind": "person", "label": "Ada"})).unwrap();
1065        assert_eq!(
1066            person,
1067            DecidedByV1::Person {
1068                label: "Ada".into()
1069            }
1070        );
1071        let threshold: DecidedByV1 =
1072            serde_json::from_value(serde_json::json!({"kind": "threshold"})).unwrap();
1073        assert_eq!(threshold, DecidedByV1::Threshold);
1074    }
1075
1076    fn fixture(name: &str) -> serde_json::Value {
1077        let path = format!("{}/tests/fixtures/hive/{name}", env!("CARGO_MANIFEST_DIR"));
1078        serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
1079    }
1080
1081    /// Hive v5: `outcome` is sent only when set (a 1.x Hive denies the
1082    /// field), and a failed result brings no draft.
1083    #[test]
1084    fn a_failed_result_names_its_outcome_and_brings_no_draft() {
1085        let mut result: TaskResultRequestV1 =
1086            serde_json::from_value(fixture("task_result.request.json")).unwrap();
1087        assert_eq!(result.outcome, None);
1088        assert!(
1089            serde_json::to_value(&result)
1090                .unwrap()
1091                .get("outcome")
1092                .is_none(),
1093            "a completed result sends no outcome"
1094        );
1095        result.outcome = Some(TaskOutcome::Failed);
1096        assert!(result.validate().is_ok());
1097        assert_eq!(serde_json::to_value(&result).unwrap()["outcome"], "failed");
1098        result.draft =
1099            Some(serde_json::from_value(fixture("proposal.import.request.json")).unwrap());
1100        assert_eq!(
1101            result.validate().unwrap_err().to_string(),
1102            "a failed result brings no draft"
1103        );
1104        result.outcome = Some(TaskOutcome::Completed);
1105        assert!(
1106            result.validate().is_ok(),
1107            "a completed result may bring one"
1108        );
1109    }
1110
1111    /// A 1.x view (`submitted`, no `lifecycle`, a proposal) and a v5 view
1112    /// (`failed`, `lifecycle`, `seq` with proposal 0) both parse; unknown
1113    /// lifecycle words stay tolerated.
1114    #[test]
1115    fn task_views_of_both_lines_parse() {
1116        let old: TaskV1 = serde_json::from_value(fixture("task.done.response.json")).unwrap();
1117        assert_eq!(old.lifecycle, None);
1118        assert_eq!(old.result.as_ref().unwrap().seq, None);
1119        assert!(
1120            serde_json::to_value(&old)
1121                .unwrap()
1122                .get("lifecycle")
1123                .is_none()
1124        );
1125
1126        let mut v5 = fixture("task.done.response.json");
1127        v5["state"] = "failed".into();
1128        v5["lifecycle"] = "failed".into();
1129        v5["result"]["proposal"] = 0.into();
1130        v5["result"]["seq"] = 42.into();
1131        let v5: TaskV1 = serde_json::from_value(v5).unwrap();
1132        assert_eq!(v5.state, TaskState::Failed);
1133        assert_eq!(v5.lifecycle, Some(TaskLifecycle::Failed));
1134        let result = v5.result.as_ref().unwrap();
1135        assert_eq!((result.proposal, result.seq), (0, Some(42)));
1136        assert!(v5.validate().is_ok());
1137
1138        let mut list = fixture("task_list.response.json");
1139        list["tasks"][0]["lifecycle"] = "paused_someday".into();
1140        let list: TaskListV1 = serde_json::from_value(list).unwrap();
1141        assert_eq!(list.tasks[0].lifecycle, Some(TaskLifecycle::Unknown));
1142        let state: TaskState = serde_json::from_value("someday".into()).unwrap();
1143        assert_eq!(state, TaskState::Unknown);
1144    }
1145
1146    fn v2_route(name: &str) -> serde_json::Value {
1147        let path = format!(
1148            "{}/tests/fixtures/hive-v2/routes/{name}.json",
1149            env!("CARGO_MANIFEST_DIR")
1150        );
1151        serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1152    }
1153
1154    /// C0 2.2 (E48, E47 fields): the contract's requests validate, its
1155    /// answers parse, and a reject without reason or an empty field change
1156    /// is refused before it is sent.
1157    #[test]
1158    fn the_c0_2_2_task_shapes_hold() {
1159        for name in ["task_verify.request", "task_verify.reject.request"] {
1160            let request: TaskVerifyRequestV1 = serde_json::from_value(v2_route(name)).unwrap();
1161            request.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
1162            assert_eq!(serde_json::to_value(&request).unwrap(), v2_route(name));
1163        }
1164        let mut reject: TaskVerifyRequestV1 =
1165            serde_json::from_value(v2_route("task_verify.reject.request")).unwrap();
1166        reject.reason = None;
1167        assert_eq!(
1168            reject.validate().unwrap_err().to_string(),
1169            "a reject names its reason"
1170        );
1171        for (name, state) in [
1172            ("task_verify.response", TaskState::Done),
1173            ("task_verify.reject.response", TaskState::Open),
1174            ("task_result.submitted.response", TaskState::Submitted),
1175        ] {
1176            let ack: TaskAckV1 = serde_json::from_value(v2_route(name)).unwrap();
1177            assert_eq!(ack.state, state, "{name}");
1178        }
1179        let fields: TaskFieldsRequestV1 =
1180            serde_json::from_value(v2_route("task_fields.request")).unwrap();
1181        fields.validate().unwrap();
1182        assert_eq!(fields.priority, Some(TaskPriority::High));
1183        let empty = TaskFieldsRequestV1 {
1184            labels: None,
1185            priority: None,
1186            ..fields.clone()
1187        };
1188        assert!(empty.validate().is_err());
1189        let bad = TaskFieldsRequestV1 {
1190            labels: Some(vec!["Docs".into()]),
1191            ..fields
1192        };
1193        assert!(bad.validate().is_err());
1194    }
1195}