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