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 contained: Option<Value>,
191 #[serde(
194 default,
195 deserialize_with = "present",
196 skip_serializing_if = "Option::is_none"
197 )]
198 pub strength: Option<Value>,
199 #[serde(
201 default,
202 deserialize_with = "present",
203 skip_serializing_if = "Option::is_none"
204 )]
205 pub instructions: Option<Value>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub decider: Option<Decider>,
209 pub policy_sha256: String,
211 pub time: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub red: Option<Red>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub bug: Option<Value>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub acceptance: Option<Value>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub regression: Option<crate::regression::RegressionReason>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub collection: Option<crate::collection::Collection>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub context: Option<Value>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub dependencies: Option<Value>,
241 #[serde(flatten)]
244 pub extra: BTreeMap<String, Value>,
245}
246
247impl TaskVerificationV0 {
248 #[must_use]
250 pub fn revision(&self) -> u32 {
251 self.revision.unwrap_or(1)
252 }
253
254 #[must_use]
257 pub fn newer_fields(&self) -> Vec<String> {
258 let mut out: Vec<String> = self.extra.keys().cloned().collect();
259 if self.revision() > REVISION {
260 out.push(format!("revision {}", self.revision()));
261 }
262 out
263 }
264
265 #[must_use]
267 pub fn reserved_present(&self) -> Vec<&'static str> {
268 [
269 ("bug", self.bug.is_some()),
270 ("acceptance", self.acceptance.is_some()),
271 ("context", self.context.is_some()),
272 ("dependencies", self.dependencies.is_some()),
273 ]
274 .into_iter()
275 .filter_map(|(n, p)| p.then_some(n))
276 .collect()
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use serde_json::json;
284
285 fn tick() -> Value {
286 json!({
287 "verdict": "verified",
288 "unit": { "format": "speckit", "feature": "001-x", "dir": "specs/001-x" },
289 "task": { "id": "T001", "line_hash": "ab", "title": "Do it (FR-001)" },
290 "tree": "t",
291 "spec": { "section": null, "section_hash": "cd" },
292 "trace": [],
293 "integrity": { "findings": [] },
294 "monitor": { "state": "off", "findings": [] },
295 "mutation": null,
296 "sandbox": { "mode": "off" },
297 "executor": { "kind": "speccoder" },
298 "model": null,
299 "run": null,
300 "decisions": [],
301 "strength": null,
302 "instructions": [],
303 "policy_sha256": "ef",
304 "time": "2026-10-26T00:00:00Z"
305 })
306 }
307
308 fn decision() -> Value {
309 json!({
310 "verdict": "attested",
311 "unit": { "format": "speckit", "feature": "001-x", "dir": "specs/001-x" },
312 "task": { "id": "T002", "line_hash": "ab", "title": "Accept it" },
313 "tree": "t",
314 "decider": { "by": "Ada", "reason": "checked by hand" },
315 "policy_sha256": "ef",
316 "time": "2026-10-26T00:00:00Z"
317 })
318 }
319
320 fn round_trip(v: &Value) -> Value {
321 let typed: TaskVerificationV0 = serde_json::from_value(v.clone()).unwrap();
322 serde_json::to_value(&typed).unwrap()
323 }
324
325 #[test]
326 fn statements_of_both_shapes_round_trip_unchanged() {
327 assert_eq!(round_trip(&tick()), tick());
328 assert_eq!(round_trip(&decision()), decision());
329 }
330
331 #[test]
332 fn unknown_fields_and_a_newer_revision_survive_and_are_named() {
333 let mut v = tick();
334 v["revision"] = json!(7);
335 v["provenance"] = json!({ "x": 1 });
336 v["bug"] = json!({ "triad_sha256": "aa" });
337 assert_eq!(round_trip(&v), v);
338 let typed: TaskVerificationV0 = serde_json::from_value(v).unwrap();
339 assert_eq!(typed.newer_fields(), ["provenance", "revision 7"]);
340 assert_eq!(typed.reserved_present(), ["bug"]);
341 }
342
343 #[test]
344 fn a_statement_without_revision_is_revision_one() {
345 let typed: TaskVerificationV0 = serde_json::from_value(tick()).unwrap();
346 assert_eq!(typed.revision(), 1);
347 assert!(typed.newer_fields().is_empty());
348 }
349
350 #[test]
351 fn a_missing_required_field_does_not_parse() {
352 let mut v = tick();
353 v.as_object_mut().unwrap().remove("tree");
354 assert!(serde_json::from_value::<TaskVerificationV0>(v).is_err());
355 }
356}