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    /// The acceptance set's strength (shares, near misses, permitted
184    /// alternatives), or `null`.
185    #[serde(
186        default,
187        deserialize_with = "present",
188        skip_serializing_if = "Option::is_none"
189    )]
190    pub strength: Option<Value>,
191    /// Repository instruction files given to the attempt.
192    #[serde(
193        default,
194        deserialize_with = "present",
195        skip_serializing_if = "Option::is_none"
196    )]
197    pub instructions: Option<Value>,
198    /// The decision's maker (`attested`, `skipped`).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub decider: Option<Decider>,
201    /// sha256 of the judge's configuration at the parent commit.
202    pub policy_sha256: String,
203    /// When it was recorded (RFC 3339, UTC).
204    pub time: String,
205    /// The red run's output excerpt (revision 2).
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub red: Option<Red>,
208    /// A bug task's evidence: where the bug lives, its triad's digest, the
209    /// outcome, the reproductions' discrimination, special-case rejections
210    /// and the pass-to-pass set. Opaque to the verifier.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub bug: Option<Value>,
213    /// Where the acceptance tests were decided —
214    /// `{ source: authored|review|bug_confirmation|person_test, pre_frozen }`.
215    /// Opaque here.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub acceptance: Option<Value>,
218    /// Why a `regression_only` tick is one (revision 3); absent on other
219    /// verdicts and in earlier revisions (read as unrecorded).
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub regression: Option<crate::regression::RegressionReason>,
222    /// The test files the attempt added or changed and whether the run
223    /// collected each (revision 3).
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub collection: Option<crate::collection::Collection>,
226    /// Reserved: what reached the model, by digest. Opaque here.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub context: Option<Value>,
229    /// Reserved: dependencies the change added, with their
230    /// registry checksums. Opaque here.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub dependencies: Option<Value>,
233    /// Fields this build does not know (a newer revision): kept, so the
234    /// signed bytes survive a round-trip, and never read for an outcome.
235    #[serde(flatten)]
236    pub extra: BTreeMap<String, Value>,
237}
238
239impl TaskVerificationV0 {
240    /// The revision the statement claims (absent: 1).
241    #[must_use]
242    pub fn revision(&self) -> u32 {
243        self.revision.unwrap_or(1)
244    }
245
246    /// What this build does not know of the statement: the names of
247    /// unknown fields, and `revision N` when it is newer than [`REVISION`].
248    #[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    /// The reserved fields present (opaque here): their names.
258    #[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}