Skip to main content

speccoder_core/
predicate.rs

1//! The `task-verification` predicate (`https://speccoder.dev/task-verification/v0`)
2//! as one typed body: what speccoder signs about a ticked task, and what a
3//! verifier reads back. Published as an open specification, its schema
4//! generated from this type.
5//!
6//! Versioned within v0 by `revision`: additive fields raise it, a removed or
7//! redefined field is a new predicate type (`…/v1`). Readers tolerate
8//! unknown fields and higher revisions: [`TaskVerificationV0::extra`] keeps
9//! them, so a statement round-trips unchanged, and the verifier names them
10//! without letting them decide anything.
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Deserializer, Serialize};
15use serde_json::Value;
16
17/// The predicate type.
18pub const TYPE: &str = "https://speccoder.dev/task-verification/v0";
19
20/// The revision this build writes. 1: the fields before `revision`
21/// existed (a statement without it); 2: `revision`, `red` and the reserved
22/// fields of other specs.
23pub const REVISION: u32 = 2;
24
25/// The longest red-output excerpt a statement carries, in bytes.
26pub const RED_EXCERPT_MAX: usize = 2048;
27
28/// A present field, `null` included, as `Some`: a statement that says
29/// `"run": null` and one that has no `run` stay apart on round-trip.
30fn present<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
31    Value::deserialize(d).map(Some)
32}
33
34/// The spec unit (feature, change) the task belongs to.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
36pub struct Unit {
37    /// The spec format, e.g. `speckit`, `openspec`.
38    pub format: String,
39    /// The unit's name, e.g. `007-config`.
40    pub feature: String,
41    /// The unit's directory, project-relative.
42    pub dir: String,
43}
44
45/// The task.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
47pub struct TaskRef {
48    /// Task ID (`T012`, `1.3`).
49    pub id: String,
50    /// sha256 of the task line with its box read as open (before the tick).
51    pub line_hash: String,
52    /// The task's title.
53    pub title: String,
54}
55
56/// The requirement the task belongs to.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
58pub struct SpecRef {
59    /// The requirement or story, e.g. `US1`; `null`: none.
60    pub section: Option<String>,
61    /// sha256 of that requirement's text as the attempt was given it.
62    pub section_hash: String,
63}
64
65/// Who decided an `attested` or `skipped` task, and why.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
67pub struct Decider {
68    /// The person.
69    pub by: String,
70    /// Their reason.
71    pub reason: String,
72}
73
74/// How the acceptance tests first failed (revision 2).
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
76#[serde(rename_all = "snake_case")]
77pub enum RedKind {
78    /// On an assertion.
79    Assertion,
80    /// Because what the task introduces did not exist yet.
81    MissingSymbol,
82}
83
84/// The red run's output, bounded and checked clean (revision 2).
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
86pub struct Red {
87    /// How the tests failed.
88    pub kind: RedKind,
89    /// The last bytes of the output (at most 2048); `null` when withheld.
90    pub excerpt: Option<String>,
91    /// sha256 of `excerpt`.
92    pub excerpt_sha256: Option<String>,
93    /// sha256 of the captured output the excerpt was cut from.
94    pub output_sha256: String,
95    /// The excerpt was not clean (a local path, a credential): dropped.
96    pub withheld: bool,
97}
98
99/// The `task-verification` predicate, v0.
100///
101/// Fields `null` in a tick may be absent in a decision (`attested`,
102/// `skipped`), which carries `decider` instead of the test evidence.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
104pub struct TaskVerificationV0 {
105    /// Additive revision within v0; absent: 1.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub revision: Option<u32>,
108    /// `verified`, `regression_only`, `attested` or `skipped`.
109    pub verdict: String,
110    /// The unit.
111    pub unit: Unit,
112    /// The task.
113    pub task: TaskRef,
114    /// The task commit's tree.
115    pub tree: String,
116    /// The requirement and its text's hash (ticks).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub spec: Option<SpecRef>,
119    /// Requirement → scenario → test rows, each with its phase and result.
120    #[serde(
121        default,
122        deserialize_with = "present",
123        skip_serializing_if = "Option::is_none"
124    )]
125    pub trace: Option<Value>,
126    /// The diff audit's findings (`{ findings: [] }`).
127    #[serde(
128        default,
129        deserialize_with = "present",
130        skip_serializing_if = "Option::is_none"
131    )]
132    pub integrity: Option<Value>,
133    /// The gaming monitor: `{ state: clean|suspected|unreviewed|off, findings }`.
134    #[serde(
135        default,
136        deserialize_with = "present",
137        skip_serializing_if = "Option::is_none"
138    )]
139    pub monitor: Option<Value>,
140    /// Mutation testing: `{ caught, missed, … }`, or `null`.
141    #[serde(
142        default,
143        deserialize_with = "present",
144        skip_serializing_if = "Option::is_none"
145    )]
146    pub mutation: Option<Value>,
147    /// Where the attempt ran: `{ mode, backend, network }`.
148    #[serde(
149        default,
150        deserialize_with = "present",
151        skip_serializing_if = "Option::is_none"
152    )]
153    pub sandbox: Option<Value>,
154    /// Who implemented: `{ kind, agent? }`.
155    #[serde(
156        default,
157        deserialize_with = "present",
158        skip_serializing_if = "Option::is_none"
159    )]
160    pub executor: Option<Value>,
161    /// The judging model: `{ provider, model }`, or `null`.
162    #[serde(
163        default,
164        deserialize_with = "present",
165        skip_serializing_if = "Option::is_none"
166    )]
167    pub model: Option<Value>,
168    /// The agentplane run, or `null`.
169    #[serde(
170        default,
171        deserialize_with = "present",
172        skip_serializing_if = "Option::is_none"
173    )]
174    pub run: Option<Value>,
175    /// People's decisions on the attempt's calls.
176    #[serde(
177        default,
178        deserialize_with = "present",
179        skip_serializing_if = "Option::is_none"
180    )]
181    pub decisions: Option<Value>,
182    /// The acceptance set's strength (shares, near misses, permitted
183    /// alternatives), or `null`.
184    #[serde(
185        default,
186        deserialize_with = "present",
187        skip_serializing_if = "Option::is_none"
188    )]
189    pub strength: Option<Value>,
190    /// Repository instruction files given to the attempt.
191    #[serde(
192        default,
193        deserialize_with = "present",
194        skip_serializing_if = "Option::is_none"
195    )]
196    pub instructions: Option<Value>,
197    /// The decision's maker (`attested`, `skipped`).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub decider: Option<Decider>,
200    /// sha256 of the judge's configuration at the parent commit.
201    pub policy_sha256: String,
202    /// When it was recorded (RFC 3339, UTC).
203    pub time: String,
204    /// The red run's output excerpt (revision 2).
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub red: Option<Red>,
207    /// A bug task's evidence: where the bug lives, its triad's digest, the
208    /// outcome, the reproductions' discrimination, special-case rejections
209    /// and the pass-to-pass set. Opaque to the verifier.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub bug: Option<Value>,
212    /// Where the acceptance tests were decided —
213    /// `{ source: authored|review|bug_confirmation|person_test, pre_frozen }`.
214    /// Opaque here.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub acceptance: Option<Value>,
217    /// Reserved: what reached the model, by digest. Opaque here.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub context: Option<Value>,
220    /// Reserved: dependencies the change added, with their
221    /// registry checksums. Opaque here.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub dependencies: Option<Value>,
224    /// Fields this build does not know (a newer revision): kept, so the
225    /// signed bytes survive a round-trip, and never read for an outcome.
226    #[serde(flatten)]
227    pub extra: BTreeMap<String, Value>,
228}
229
230impl TaskVerificationV0 {
231    /// The revision the statement claims (absent: 1).
232    #[must_use]
233    pub fn revision(&self) -> u32 {
234        self.revision.unwrap_or(1)
235    }
236
237    /// What this build does not know of the statement: the names of
238    /// unknown fields, and `revision N` when it is newer than [`REVISION`].
239    #[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    /// The reserved fields present (opaque here): their names.
249    #[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}