use std::collections::BTreeMap;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
pub const TYPE: &str = "https://speccoder.dev/task-verification/v0";
pub const REVISION: u32 = 3;
pub const RED_EXCERPT_MAX: usize = 2048;
fn present<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
Value::deserialize(d).map(Some)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Unit {
pub format: String,
pub feature: String,
pub dir: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TaskRef {
pub id: String,
pub line_hash: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SpecRef {
pub section: Option<String>,
pub section_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Decider {
pub by: String,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RedKind {
Assertion,
MissingSymbol,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Red {
pub kind: RedKind,
pub excerpt: Option<String>,
pub excerpt_sha256: Option<String>,
pub output_sha256: String,
pub withheld: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TaskVerificationV0 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<u32>,
pub verdict: String,
pub unit: Unit,
pub task: TaskRef,
pub tree: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec: Option<SpecRef>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub trace: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub integrity: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub monitor: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub mutation: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub sandbox: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub executor: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub model: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub run: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub decisions: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub strength: Option<Value>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub instructions: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decider: Option<Decider>,
pub policy_sha256: String,
pub time: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub red: Option<Red>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bug: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acceptance: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regression: Option<crate::regression::RegressionReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collection: Option<crate::collection::Collection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dependencies: Option<Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
impl TaskVerificationV0 {
#[must_use]
pub fn revision(&self) -> u32 {
self.revision.unwrap_or(1)
}
#[must_use]
pub fn newer_fields(&self) -> Vec<String> {
let mut out: Vec<String> = self.extra.keys().cloned().collect();
if self.revision() > REVISION {
out.push(format!("revision {}", self.revision()));
}
out
}
#[must_use]
pub fn reserved_present(&self) -> Vec<&'static str> {
[
("bug", self.bug.is_some()),
("acceptance", self.acceptance.is_some()),
("context", self.context.is_some()),
("dependencies", self.dependencies.is_some()),
]
.into_iter()
.filter_map(|(n, p)| p.then_some(n))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tick() -> Value {
json!({
"verdict": "verified",
"unit": { "format": "speckit", "feature": "001-x", "dir": "specs/001-x" },
"task": { "id": "T001", "line_hash": "ab", "title": "Do it (FR-001)" },
"tree": "t",
"spec": { "section": null, "section_hash": "cd" },
"trace": [],
"integrity": { "findings": [] },
"monitor": { "state": "off", "findings": [] },
"mutation": null,
"sandbox": { "mode": "off" },
"executor": { "kind": "speccoder" },
"model": null,
"run": null,
"decisions": [],
"strength": null,
"instructions": [],
"policy_sha256": "ef",
"time": "2026-10-26T00:00:00Z"
})
}
fn decision() -> Value {
json!({
"verdict": "attested",
"unit": { "format": "speckit", "feature": "001-x", "dir": "specs/001-x" },
"task": { "id": "T002", "line_hash": "ab", "title": "Accept it" },
"tree": "t",
"decider": { "by": "Ada", "reason": "checked by hand" },
"policy_sha256": "ef",
"time": "2026-10-26T00:00:00Z"
})
}
fn round_trip(v: &Value) -> Value {
let typed: TaskVerificationV0 = serde_json::from_value(v.clone()).unwrap();
serde_json::to_value(&typed).unwrap()
}
#[test]
fn statements_of_both_shapes_round_trip_unchanged() {
assert_eq!(round_trip(&tick()), tick());
assert_eq!(round_trip(&decision()), decision());
}
#[test]
fn unknown_fields_and_a_newer_revision_survive_and_are_named() {
let mut v = tick();
v["revision"] = json!(7);
v["provenance"] = json!({ "x": 1 });
v["bug"] = json!({ "triad_sha256": "aa" });
assert_eq!(round_trip(&v), v);
let typed: TaskVerificationV0 = serde_json::from_value(v).unwrap();
assert_eq!(typed.newer_fields(), ["provenance", "revision 7"]);
assert_eq!(typed.reserved_present(), ["bug"]);
}
#[test]
fn a_statement_without_revision_is_revision_one() {
let typed: TaskVerificationV0 = serde_json::from_value(tick()).unwrap();
assert_eq!(typed.revision(), 1);
assert!(typed.newer_fields().is_empty());
}
#[test]
fn a_missing_required_field_does_not_parse() {
let mut v = tick();
v.as_object_mut().unwrap().remove("tree");
assert!(serde_json::from_value::<TaskVerificationV0>(v).is_err());
}
}