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 = 2;
24
25pub const RED_EXCERPT_MAX: usize = 2048;
27
28fn present<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
31 Value::deserialize(d).map(Some)
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
36pub struct Unit {
37 pub format: String,
39 pub feature: String,
41 pub dir: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
47pub struct TaskRef {
48 pub id: String,
50 pub line_hash: String,
52 pub title: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
58pub struct SpecRef {
59 pub section: Option<String>,
61 pub section_hash: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
67pub struct Decider {
68 pub by: String,
70 pub reason: String,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
76#[serde(rename_all = "snake_case")]
77pub enum RedKind {
78 Assertion,
80 MissingSymbol,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
86pub struct Red {
87 pub kind: RedKind,
89 pub excerpt: Option<String>,
91 pub excerpt_sha256: Option<String>,
93 pub output_sha256: String,
95 pub withheld: bool,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
104pub struct TaskVerificationV0 {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub revision: Option<u32>,
108 pub verdict: String,
110 pub unit: Unit,
112 pub task: TaskRef,
114 pub tree: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub spec: Option<SpecRef>,
119 #[serde(
121 default,
122 deserialize_with = "present",
123 skip_serializing_if = "Option::is_none"
124 )]
125 pub trace: Option<Value>,
126 #[serde(
128 default,
129 deserialize_with = "present",
130 skip_serializing_if = "Option::is_none"
131 )]
132 pub integrity: Option<Value>,
133 #[serde(
135 default,
136 deserialize_with = "present",
137 skip_serializing_if = "Option::is_none"
138 )]
139 pub monitor: Option<Value>,
140 #[serde(
142 default,
143 deserialize_with = "present",
144 skip_serializing_if = "Option::is_none"
145 )]
146 pub mutation: Option<Value>,
147 #[serde(
149 default,
150 deserialize_with = "present",
151 skip_serializing_if = "Option::is_none"
152 )]
153 pub sandbox: Option<Value>,
154 #[serde(
156 default,
157 deserialize_with = "present",
158 skip_serializing_if = "Option::is_none"
159 )]
160 pub executor: Option<Value>,
161 #[serde(
163 default,
164 deserialize_with = "present",
165 skip_serializing_if = "Option::is_none"
166 )]
167 pub model: Option<Value>,
168 #[serde(
170 default,
171 deserialize_with = "present",
172 skip_serializing_if = "Option::is_none"
173 )]
174 pub run: Option<Value>,
175 #[serde(
177 default,
178 deserialize_with = "present",
179 skip_serializing_if = "Option::is_none"
180 )]
181 pub decisions: Option<Value>,
182 #[serde(
185 default,
186 deserialize_with = "present",
187 skip_serializing_if = "Option::is_none"
188 )]
189 pub strength: Option<Value>,
190 #[serde(
192 default,
193 deserialize_with = "present",
194 skip_serializing_if = "Option::is_none"
195 )]
196 pub instructions: Option<Value>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub decider: Option<Decider>,
200 pub policy_sha256: String,
202 pub time: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub red: Option<Red>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub bug: Option<Value>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub acceptance: Option<Value>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub context: Option<Value>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub dependencies: Option<Value>,
224 #[serde(flatten)]
227 pub extra: BTreeMap<String, Value>,
228}
229
230impl TaskVerificationV0 {
231 #[must_use]
233 pub fn revision(&self) -> u32 {
234 self.revision.unwrap_or(1)
235 }
236
237 #[must_use]
240 pub fn newer_fields(&self) -> Vec<String> {
241 let mut out: Vec<String> = self.extra.keys().cloned().collect();
242 if self.revision() > REVISION {
243 out.push(format!("revision {}", self.revision()));
244 }
245 out
246 }
247
248 #[must_use]
250 pub fn reserved_present(&self) -> Vec<&'static str> {
251 [
252 ("bug", self.bug.is_some()),
253 ("acceptance", self.acceptance.is_some()),
254 ("context", self.context.is_some()),
255 ("dependencies", self.dependencies.is_some()),
256 ]
257 .into_iter()
258 .filter_map(|(n, p)| p.then_some(n))
259 .collect()
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use serde_json::json;
267
268 fn tick() -> Value {
269 json!({
270 "verdict": "verified",
271 "unit": { "format": "speckit", "feature": "001-x", "dir": "specs/001-x" },
272 "task": { "id": "T001", "line_hash": "ab", "title": "Do it (FR-001)" },
273 "tree": "t",
274 "spec": { "section": null, "section_hash": "cd" },
275 "trace": [],
276 "integrity": { "findings": [] },
277 "monitor": { "state": "off", "findings": [] },
278 "mutation": null,
279 "sandbox": { "mode": "off" },
280 "executor": { "kind": "speccoder" },
281 "model": null,
282 "run": null,
283 "decisions": [],
284 "strength": null,
285 "instructions": [],
286 "policy_sha256": "ef",
287 "time": "2026-10-26T00:00:00Z"
288 })
289 }
290
291 fn decision() -> Value {
292 json!({
293 "verdict": "attested",
294 "unit": { "format": "speckit", "feature": "001-x", "dir": "specs/001-x" },
295 "task": { "id": "T002", "line_hash": "ab", "title": "Accept it" },
296 "tree": "t",
297 "decider": { "by": "Ada", "reason": "checked by hand" },
298 "policy_sha256": "ef",
299 "time": "2026-10-26T00:00:00Z"
300 })
301 }
302
303 fn round_trip(v: &Value) -> Value {
304 let typed: TaskVerificationV0 = serde_json::from_value(v.clone()).unwrap();
305 serde_json::to_value(&typed).unwrap()
306 }
307
308 #[test]
309 fn statements_of_both_shapes_round_trip_unchanged() {
310 assert_eq!(round_trip(&tick()), tick());
311 assert_eq!(round_trip(&decision()), decision());
312 }
313
314 #[test]
315 fn unknown_fields_and_a_newer_revision_survive_and_are_named() {
316 let mut v = tick();
317 v["revision"] = json!(7);
318 v["provenance"] = json!({ "x": 1 });
319 v["bug"] = json!({ "triad_sha256": "aa" });
320 assert_eq!(round_trip(&v), v);
321 let typed: TaskVerificationV0 = serde_json::from_value(v).unwrap();
322 assert_eq!(typed.newer_fields(), ["provenance", "revision 7"]);
323 assert_eq!(typed.reserved_present(), ["bug"]);
324 }
325
326 #[test]
327 fn a_statement_without_revision_is_revision_one() {
328 let typed: TaskVerificationV0 = serde_json::from_value(tick()).unwrap();
329 assert_eq!(typed.revision(), 1);
330 assert!(typed.newer_fields().is_empty());
331 }
332
333 #[test]
334 fn a_missing_required_field_does_not_parse() {
335 let mut v = tick();
336 v.as_object_mut().unwrap().remove("tree");
337 assert!(serde_json::from_value::<TaskVerificationV0>(v).is_err());
338 }
339}