1use std::collections::BTreeMap;
13
14use serde::{Deserialize, Deserializer, Serialize};
15use serde_json::Value;
16
17pub const TYPE: &str = "https://speccoder.dev/task-verification/v0";
19
20pub const REVISION: u32 = 3;
25
26pub const RED_EXCERPT_MAX: usize = 2048;
28
29fn present<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
32 Value::deserialize(d).map(Some)
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
37pub struct Unit {
38 pub format: String,
40 pub feature: String,
42 pub dir: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
48pub struct TaskRef {
49 pub id: String,
51 pub line_hash: String,
53 pub title: String,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
59pub struct SpecRef {
60 pub section: Option<String>,
62 pub section_hash: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
68pub struct Decider {
69 pub by: String,
71 pub reason: String,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
77#[serde(rename_all = "snake_case")]
78pub enum RedKind {
79 Assertion,
81 MissingSymbol,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
87pub struct Red {
88 pub kind: RedKind,
90 pub excerpt: Option<String>,
92 pub excerpt_sha256: Option<String>,
94 pub output_sha256: String,
96 pub withheld: bool,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
105pub struct TaskVerificationV0 {
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub revision: Option<u32>,
109 pub verdict: String,
111 pub unit: Unit,
113 pub task: TaskRef,
115 pub tree: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub spec: Option<SpecRef>,
120 #[serde(
122 default,
123 deserialize_with = "present",
124 skip_serializing_if = "Option::is_none"
125 )]
126 pub trace: Option<Value>,
127 #[serde(
129 default,
130 deserialize_with = "present",
131 skip_serializing_if = "Option::is_none"
132 )]
133 pub integrity: Option<Value>,
134 #[serde(
136 default,
137 deserialize_with = "present",
138 skip_serializing_if = "Option::is_none"
139 )]
140 pub monitor: Option<Value>,
141 #[serde(
143 default,
144 deserialize_with = "present",
145 skip_serializing_if = "Option::is_none"
146 )]
147 pub mutation: Option<Value>,
148 #[serde(
150 default,
151 deserialize_with = "present",
152 skip_serializing_if = "Option::is_none"
153 )]
154 pub sandbox: Option<Value>,
155 #[serde(
157 default,
158 deserialize_with = "present",
159 skip_serializing_if = "Option::is_none"
160 )]
161 pub executor: Option<Value>,
162 #[serde(
164 default,
165 deserialize_with = "present",
166 skip_serializing_if = "Option::is_none"
167 )]
168 pub model: Option<Value>,
169 #[serde(
171 default,
172 deserialize_with = "present",
173 skip_serializing_if = "Option::is_none"
174 )]
175 pub run: Option<Value>,
176 #[serde(
178 default,
179 deserialize_with = "present",
180 skip_serializing_if = "Option::is_none"
181 )]
182 pub decisions: Option<Value>,
183 #[serde(
186 default,
187 deserialize_with = "present",
188 skip_serializing_if = "Option::is_none"
189 )]
190 pub strength: Option<Value>,
191 #[serde(
193 default,
194 deserialize_with = "present",
195 skip_serializing_if = "Option::is_none"
196 )]
197 pub instructions: Option<Value>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub decider: Option<Decider>,
201 pub policy_sha256: String,
203 pub time: String,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub red: Option<Red>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub bug: Option<Value>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub acceptance: Option<Value>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub regression: Option<crate::regression::RegressionReason>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub collection: Option<crate::collection::Collection>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub context: Option<Value>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub dependencies: Option<Value>,
233 #[serde(flatten)]
236 pub extra: BTreeMap<String, Value>,
237}
238
239impl TaskVerificationV0 {
240 #[must_use]
242 pub fn revision(&self) -> u32 {
243 self.revision.unwrap_or(1)
244 }
245
246 #[must_use]
249 pub fn newer_fields(&self) -> Vec<String> {
250 let mut out: Vec<String> = self.extra.keys().cloned().collect();
251 if self.revision() > REVISION {
252 out.push(format!("revision {}", self.revision()));
253 }
254 out
255 }
256
257 #[must_use]
259 pub fn reserved_present(&self) -> Vec<&'static str> {
260 [
261 ("bug", self.bug.is_some()),
262 ("acceptance", self.acceptance.is_some()),
263 ("context", self.context.is_some()),
264 ("dependencies", self.dependencies.is_some()),
265 ]
266 .into_iter()
267 .filter_map(|(n, p)| p.then_some(n))
268 .collect()
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use serde_json::json;
276
277 fn tick() -> Value {
278 json!({
279 "verdict": "verified",
280 "unit": { "format": "speckit", "feature": "001-x", "dir": "specs/001-x" },
281 "task": { "id": "T001", "line_hash": "ab", "title": "Do it (FR-001)" },
282 "tree": "t",
283 "spec": { "section": null, "section_hash": "cd" },
284 "trace": [],
285 "integrity": { "findings": [] },
286 "monitor": { "state": "off", "findings": [] },
287 "mutation": null,
288 "sandbox": { "mode": "off" },
289 "executor": { "kind": "speccoder" },
290 "model": null,
291 "run": null,
292 "decisions": [],
293 "strength": null,
294 "instructions": [],
295 "policy_sha256": "ef",
296 "time": "2026-10-26T00:00:00Z"
297 })
298 }
299
300 fn decision() -> Value {
301 json!({
302 "verdict": "attested",
303 "unit": { "format": "speckit", "feature": "001-x", "dir": "specs/001-x" },
304 "task": { "id": "T002", "line_hash": "ab", "title": "Accept it" },
305 "tree": "t",
306 "decider": { "by": "Ada", "reason": "checked by hand" },
307 "policy_sha256": "ef",
308 "time": "2026-10-26T00:00:00Z"
309 })
310 }
311
312 fn round_trip(v: &Value) -> Value {
313 let typed: TaskVerificationV0 = serde_json::from_value(v.clone()).unwrap();
314 serde_json::to_value(&typed).unwrap()
315 }
316
317 #[test]
318 fn statements_of_both_shapes_round_trip_unchanged() {
319 assert_eq!(round_trip(&tick()), tick());
320 assert_eq!(round_trip(&decision()), decision());
321 }
322
323 #[test]
324 fn unknown_fields_and_a_newer_revision_survive_and_are_named() {
325 let mut v = tick();
326 v["revision"] = json!(7);
327 v["provenance"] = json!({ "x": 1 });
328 v["bug"] = json!({ "triad_sha256": "aa" });
329 assert_eq!(round_trip(&v), v);
330 let typed: TaskVerificationV0 = serde_json::from_value(v).unwrap();
331 assert_eq!(typed.newer_fields(), ["provenance", "revision 7"]);
332 assert_eq!(typed.reserved_present(), ["bug"]);
333 }
334
335 #[test]
336 fn a_statement_without_revision_is_revision_one() {
337 let typed: TaskVerificationV0 = serde_json::from_value(tick()).unwrap();
338 assert_eq!(typed.revision(), 1);
339 assert!(typed.newer_fields().is_empty());
340 }
341
342 #[test]
343 fn a_missing_required_field_does_not_parse() {
344 let mut v = tick();
345 v.as_object_mut().unwrap().remove("tree");
346 assert!(serde_json::from_value::<TaskVerificationV0>(v).is_err());
347 }
348}