Skip to main content

speccoder_runtime/attest/
mod.rs

1//! Signed, portable evidence per ticked task.
2//!
3//! After a tick commit, the engine writes an in-toto Bundle as the commit's
4//! git note (`refs/notes/speccoder`): a `test-result` statement per test
5//! phase that ran, one SLSA verification summary and speccoder's
6//! `task-verification`, each a DSSE envelope signed with the person's git
7//! signing key. `accept` and `skip` write a `task-verification` alone. The
8//! model never sees, writes or signs any of it.
9
10pub mod branch;
11pub mod check;
12pub mod dsse;
13pub mod notes;
14pub mod policy;
15pub mod sign;
16pub mod statement;
17pub(crate) mod tree;
18pub mod verify;
19
20use std::path::Path;
21
22use serde_json::{Value, json};
23use speccoder_core::RunReport;
24use speccoder_core::predicate::{self, Decider, TaskRef, TaskVerificationV0, Unit};
25
26use self::dsse::Envelope;
27use self::statement::{Phase, PhaseResult, Subject};
28use crate::substrate::run_config::AttestConfig;
29
30/// What a tick's attestation came to, for the run report.
31#[must_use]
32pub fn outcome(signed: bool, signer: Option<&str>, reason: Option<&str>) -> Value {
33    json!({
34        "state": if signed { "signed" } else { "unsigned" },
35        "signer": signer,
36        "reason": reason,
37    })
38}
39
40/// Everything a tick's statements are made of besides the run report.
41#[derive(Debug)]
42pub struct Tick<'a> {
43    /// The repository (a worktree or the checkout: refs are shared).
44    pub repo: &'a Path,
45    /// `speccoder.toml` as the attempt read it.
46    pub config: &'a str,
47    /// `[attest]`.
48    pub attest: &'a AttestConfig,
49    /// The spec format, e.g. `speckit`.
50    pub format: &'a str,
51    /// The unit (feature) and its directory.
52    pub feature: &'a str,
53    /// The unit's directory, relative to the project.
54    pub feature_dir: &'a str,
55    /// Task ID.
56    pub task: &'a str,
57    /// Task title.
58    pub title: &'a str,
59    /// The task line's hash before the tick.
60    pub line_hash: &'a str,
61    /// The requirement the task belongs to, e.g. `US1`.
62    pub section: Option<&'a str>,
63    /// That requirement's text, as the attempt was given it.
64    pub section_text: &'a str,
65    /// The unit's requirement identifiers with their line hashes (the
66    /// format's; the trace names those the title cites).
67    pub requirements: &'a [(String, String)],
68    /// The tick commit.
69    pub commit: &'a str,
70    /// The verdict word.
71    pub verdict: &'a str,
72    /// `{ mode, backend, network }`.
73    pub sandbox: Value,
74    /// `{ kind, agent? }`.
75    pub executor: Value,
76    /// `{ provider, model }`, when one judged.
77    pub model: Value,
78    /// A bug task: the predicate's `bug` and `acceptance` fields.
79    pub bug: Option<(Value, Value)>,
80}
81
82/// Whether a task title cites requirement `id`, as a word of its own.
83#[must_use]
84pub fn cites(title: &str, id: &str) -> bool {
85    title
86        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
87        .any(|word| word == id)
88}
89
90#[cfg(feature = "engine")]
91/// The `trace[]` rows of a task's statement: each requirement its title
92/// cites, with the defining line's hash; then each acceptance test with
93/// the scenario it checks (none: the author cited none). All with the
94/// verdict.
95fn trace(t: &Tick<'_>, report: &RunReport) -> Vec<Value> {
96    let mut rows: Vec<Value> = t
97        .requirements
98        .iter()
99        .filter(|(id, _)| cites(t.title, id))
100        .map(|(id, hash)| {
101            json!({ "requirement": id, "requirement_hash": hash, "phase": "green", "result": t.verdict })
102        })
103        .collect();
104    let tests = report
105        .acceptance
106        .as_ref()
107        .and_then(|a| a["tests"].as_array())
108        .into_iter()
109        .flatten();
110    for test in tests {
111        let path = test["path"].as_str().unwrap_or_default();
112        let cited: Vec<&str> = test["scenarios"]
113            .as_array()
114            .into_iter()
115            .flatten()
116            .filter_map(Value::as_str)
117            .collect();
118        let row = |scenario: Option<&str>| {
119            json!({
120                "requirement": t.section,
121                "scenario": scenario,
122                "test": path,
123                "phase": "green",
124                "result": t.verdict,
125            })
126        };
127        if cited.is_empty() {
128            rows.push(row(None));
129        }
130        rows.extend(cited.into_iter().map(|s| row(Some(s))));
131    }
132    rows
133}
134
135/// RFC 3339 UTC, to the second.
136#[must_use]
137pub fn now_rfc3339() -> String {
138    let secs = std::time::SystemTime::now()
139        .duration_since(std::time::UNIX_EPOCH)
140        .map_or(0, |d| d.as_secs());
141    let days = i64::try_from(secs / 86_400).unwrap_or(0);
142    let rem = secs % 86_400;
143    // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
144    let z = days + 719_468;
145    let era = z.div_euclid(146_097);
146    let doe = z.rem_euclid(146_097);
147    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
148    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
149    let mp = (5 * doy + 2) / 153;
150    let day = doy - (153 * mp + 2) / 5 + 1;
151    let month = if mp < 10 { mp + 3 } else { mp - 9 };
152    let year = yoe + era * 400 + i64::from(month <= 2);
153    format!(
154        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
155        rem / 3600,
156        rem % 3600 / 60,
157        rem % 60
158    )
159}
160
161fn sha(text: &str) -> String {
162    crate::integrity::sha256_hex(text.as_bytes())
163}
164
165/// The test phases a run report shows.
166#[must_use]
167pub fn phases(report: &RunReport) -> Vec<PhaseResult> {
168    let mut out = Vec::new();
169    let checks_config = report
170        .verification
171        .as_ref()
172        .map(|v| {
173            v.checks
174                .iter()
175                .map(|c| c.argv.join(" "))
176                .collect::<Vec<_>>()
177                .join("\n")
178        })
179        .unwrap_or_default();
180    if let Some(cmp) = report
181        .verification
182        .as_ref()
183        .and_then(|v| v.comparison.as_ref())
184    {
185        out.push(PhaseResult {
186            phase: Phase::Baseline,
187            result: if cmp.pre_existing.is_empty() {
188                "PASSED"
189            } else {
190                "WARNED"
191            },
192            passed: Vec::new(),
193            warned: cmp.pre_existing.clone(),
194            failed: Vec::new(),
195            config_sha256: sha(&checks_config),
196        });
197    }
198    let acceptance = report.acceptance.as_ref();
199    let acceptance_config = acceptance
200        .map(|a| a["argv"].to_string())
201        .unwrap_or_default();
202    let acceptance_files: Vec<String> = acceptance
203        .and_then(|a| a["files"].as_array())
204        .map(|f| {
205            f.iter()
206                .filter_map(|x| x.as_str().map(str::to_owned))
207                .collect()
208        })
209        .unwrap_or_default();
210    if acceptance.is_some_and(|a| a["red"] == true) {
211        out.push(PhaseResult {
212            phase: Phase::Red,
213            result: "FAILED",
214            passed: Vec::new(),
215            warned: Vec::new(),
216            failed: acceptance_files.clone(),
217            config_sha256: sha(&acceptance_config),
218        });
219    }
220    if let Some(v) = &report.verification {
221        let mut passed = Vec::new();
222        let mut failed = Vec::new();
223        for c in &v.checks {
224            match &c.tests {
225                Some(tests) => {
226                    for t in tests {
227                        let id = format!("{}::{}", c.name, t.id);
228                        match t.outcome {
229                            speccoder_core::verify::TestOutcome::Passed => passed.push(id),
230                            speccoder_core::verify::TestOutcome::Failed
231                            | speccoder_core::verify::TestOutcome::Error => failed.push(id),
232                            speccoder_core::verify::TestOutcome::Skipped => {}
233                        }
234                    }
235                }
236                None if c.status == speccoder_core::verify::CheckStatus::Passed => {
237                    passed.push(c.name.clone());
238                }
239                None => failed.push(c.name.clone()),
240            }
241        }
242        passed.extend(acceptance_files);
243        out.push(PhaseResult {
244            phase: Phase::Green,
245            result: if v.passed { "PASSED" } else { "FAILED" },
246            passed,
247            warned: Vec::new(),
248            failed,
249            config_sha256: sha(&format!("{checks_config}\n{acceptance_config}")),
250        });
251    }
252    if let Some(h) = acceptance
253        .and_then(|a| a.get("held_out"))
254        .filter(|h| h["state"] == "on")
255    {
256        out.push(PhaseResult {
257            phase: Phase::HeldOut,
258            result: "PASSED",
259            passed: Vec::new(),
260            warned: Vec::new(),
261            failed: Vec::new(),
262            config_sha256: h["manifest"]
263                .as_str()
264                .unwrap_or_default()
265                .trim_start_matches("sha256:")
266                .to_owned(),
267        });
268    }
269    out
270}
271
272#[cfg(feature = "engine")]
273/// The decisions people made on the attempt's calls.
274fn decisions(tool_calls: &[Value]) -> Vec<Value> {
275    tool_calls
276        .iter()
277        .filter(|c| c["by"] == "person" || c["by"] == "expiry")
278        .map(|c| {
279            json!({
280                "verb": if c["approved_by"] == "person" { "approve" } else if c["expired"] == true { "expired" } else { "reject" },
281                "grant": serde_json::from_value::<crate::tools::policy::Object>(c["object"].clone()).ok().map(|o| o.describe()),
282                "scope": c["scope"],
283                "reason": c["rejection"],
284                "by": c["operator"]["actor"],
285                "basis": c["operator"]["basis"],
286                // Through what: the decision's own record (`approver`).
287                "via": if c["via"].is_null() { &c["approver"]["via"] } else { &c["via"] },
288            })
289        })
290        .collect()
291}
292
293#[cfg(feature = "engine")]
294/// The acceptance set's strength as evidence: shares, counts,
295/// what got through, the permitted alternatives and the rulings — never the
296/// local patch paths or the variants' code.
297fn strength(report: &RunReport) -> Value {
298    let Some(a) = &report.strength else {
299        return Value::Null;
300    };
301    json!({
302        "visible": a.visible,
303        "combined": a.combined,
304        "held_out": a.held_out,
305        "gate": a.gate,
306        "near_misses": a.near_misses.iter().map(|n| json!({ "id": n.id, "scenario": n.scenario, "visible": n.visible, "combined": n.combined })).collect::<Vec<_>>(),
307        "permitted": a.permitted.iter().map(|p| json!({ "id": p.id, "scenario": p.scenario, "digest": p.digest, "outcome": p.outcome })).collect::<Vec<_>>(),
308        "rulings": a.rulings,
309    })
310}
311
312#[cfg(feature = "engine")]
313/// The monitor's result, `unreviewed` when it did not finish, `off` when
314/// not configured.
315fn monitor(report: &RunReport) -> Value {
316    match &report.monitor {
317        None => json!({ "state": "off", "findings": [] }),
318        Some(m) if m.get("error").is_some() => json!({ "state": "unreviewed", "findings": [] }),
319        Some(m) => json!({
320            "state": if m["suspected"] == true { "suspected" } else { "clean" },
321            "findings": m.get("findings").cloned().unwrap_or_else(|| json!([])),
322        }),
323    }
324}
325
326/// `git+<origin without credentials>@<commit>`, or `git:<commit>`.
327fn resource_uri(repo: &Path, commit: &str) -> String {
328    let origin = crate::git::Git::at(repo)
329        .line(&["remote", "get-url", "origin"])
330        .ok()
331        .filter(|u| !u.is_empty());
332    match origin {
333        Some(url) => {
334            // Credentials in a remote URL never leave the machine.
335            let clean = match url.split_once("://") {
336                Some((scheme, rest)) => {
337                    let host_end = rest.find('/').unwrap_or(rest.len());
338                    match rest[..host_end].rfind('@') {
339                        Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
340                        None => url.clone(),
341                    }
342                }
343                None => url.clone(),
344            };
345            format!("git+{clean}@{commit}")
346        }
347        None => format!("git:{commit}"),
348    }
349}
350
351/// What `object`'s note says, signatures unchecked: `signed <kind>
352/// <keyid>`, `unsigned` or `missing`.
353#[must_use]
354pub fn note_state(repo: &Path, object: &str) -> String {
355    use base64::Engine as _;
356    let Some(note) = notes::read(repo, object) else {
357        return "missing".to_owned();
358    };
359    let (lines, _) = dsse::decode(&note);
360    if lines.is_empty() {
361        return "missing".to_owned();
362    }
363    let Some(sig) = lines
364        .iter()
365        .find_map(|l| l.signatures.first().map(|s| (s, l)))
366    else {
367        return "unsigned".to_owned();
368    };
369    let armored = base64::engine::general_purpose::STANDARD
370        .decode(&sig.0.sig)
371        .map(|b| String::from_utf8_lossy(&b).into_owned())
372        .unwrap_or_default();
373    let kind = if sig.1.sigstore.is_some() {
374        "sigstore"
375    } else if armored.contains("SSH SIGNATURE") {
376        "ssh"
377    } else {
378        "openpgp"
379    };
380    format!("signed {kind} {}", sig.0.keyid)
381}
382
383/// Signs every envelope that is not signed yet; the first failure's reason
384/// when some stay unsigned.
385pub fn sign_all(lines: &mut [Envelope], signer: &sign::Signer) -> Option<String> {
386    let mut failure = None;
387    // Keyless Sigstore signs the summary alone — it names every other line
388    // by digest — so a tick costs one identity login, not one per line.
389    let summary_only = matches!(signer, sign::Signer::Sigstore)
390        && lines.iter().any(|l| {
391            l.statement()
392                .is_some_and(|s| s["predicateType"] == statement::VSA)
393        });
394    for line in lines.iter_mut().filter(|l| l.signatures.is_empty()) {
395        if summary_only
396            && !line
397                .statement()
398                .is_some_and(|s| s["predicateType"] == statement::VSA)
399        {
400            continue;
401        }
402        let Some(bytes) = line.signed_bytes() else {
403            continue;
404        };
405        match sign::sign(signer, &bytes) {
406            Ok((sig, material)) => {
407                line.signatures.push(sig);
408                line.sigstore = material;
409            }
410            Err(e) => {
411                failure.get_or_insert(e);
412            }
413        }
414    }
415    failure
416}
417
418/// Writes `lines` as `commit`'s note, signed as `[attest]` says; what it
419/// came to (`signed`/`unsigned`, signer, reason).
420fn write(repo: &Path, commit: &str, attest: &AttestConfig, mut lines: Vec<Envelope>) -> Value {
421    let (signed, signer, reason) = if attest.sign_later {
422        (
423            false,
424            None,
425            Some("[attest] sign_at = \"later\": `speccoder attest sign`".to_owned()),
426        )
427    } else {
428        match sign::signer(repo, attest) {
429            Ok(s) => match sign_all(&mut lines, &s) {
430                None => (true, Some(s.kind()), None),
431                Some(e) => (false, Some(s.kind()), Some(e)),
432            },
433            Err(e) => (false, None, Some(e)),
434        }
435    };
436    if let Err(e) = notes::write(repo, commit, &dsse::encode(&lines)) {
437        return json!({ "state": "missing", "reason": format!("writing the note failed: {e}") });
438    }
439    outcome(signed, signer, reason.as_deref())
440}
441
442#[cfg(feature = "engine")]
443/// The red run's excerpt for the predicate, when the tests went red and
444/// their output was kept (`acceptance.red_output`).
445fn red(report: &RunReport) -> Option<predicate::Red> {
446    let a = report.acceptance.as_ref().filter(|a| a["red"] == true)?;
447    red_of(a["red_class"].as_str()?, a["red_output"].as_str()?)
448}
449
450/// The last at most [`predicate::RED_EXCERPT_MAX`] bytes of `output` (on a
451/// character boundary), with its digests; withheld when not clean (a local
452/// path, a credential): only clean text is signed. `None` for a class that
453/// is not red.
454#[must_use]
455pub fn red_of(class: &str, output: &str) -> Option<predicate::Red> {
456    let kind = match class {
457        "assertion" => predicate::RedKind::Assertion,
458        "absent" => predicate::RedKind::MissingSymbol,
459        _ => return None,
460    };
461    let mut start = output.len().saturating_sub(predicate::RED_EXCERPT_MAX);
462    while !output.is_char_boundary(start) {
463        start += 1;
464    }
465    let excerpt = output[start..].trim_start_matches('\n');
466    let clean = statement::check_clean(&json!(excerpt)).is_ok();
467    Some(predicate::Red {
468        kind,
469        excerpt: clean.then(|| excerpt.to_owned()),
470        excerpt_sha256: clean.then(|| sha(excerpt)),
471        output_sha256: sha(output),
472        withheld: !clean,
473    })
474}
475
476/// The predicate's fields every statement of `t` has; the rest `None`.
477fn predicate(
478    t: &Tick<'_>,
479    tree: String,
480    policy_sha256: String,
481    time: String,
482) -> TaskVerificationV0 {
483    TaskVerificationV0 {
484        revision: Some(predicate::REVISION),
485        verdict: t.verdict.to_owned(),
486        unit: Unit {
487            format: t.format.to_owned(),
488            feature: t.feature.to_owned(),
489            dir: t.feature_dir.to_owned(),
490        },
491        task: TaskRef {
492            id: t.task.to_owned(),
493            line_hash: t.line_hash.to_owned(),
494            title: t.title.to_owned(),
495        },
496        tree,
497        spec: None,
498        trace: None,
499        integrity: None,
500        monitor: None,
501        mutation: None,
502        sandbox: None,
503        executor: None,
504        model: None,
505        run: None,
506        decisions: None,
507        strength: None,
508        instructions: None,
509        decider: None,
510        policy_sha256,
511        time,
512        red: None,
513        bug: None,
514        acceptance: None,
515        context: None,
516        dependencies: None,
517        extra: std::collections::BTreeMap::new(),
518    }
519}
520
521/// Records a passed tick: `test-result` per phase, `task-verification`,
522/// then the `vsa` naming every other line. Never fails the tick.
523#[must_use]
524#[cfg(feature = "engine")]
525pub fn record_tick(t: &Tick<'_>, report: &RunReport) -> Value {
526    let subject = Subject {
527        name: format!("{}/{}", t.feature, t.task),
528        commit: t.commit.to_owned(),
529    };
530    let tree = crate::git::Git::at(t.repo)
531        .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
532        .unwrap_or_default();
533    let policy = policy::judge_config_sha256(t.config).unwrap_or_default();
534    let time = now_rfc3339();
535    let mut named: Vec<(String, Value)> = phases(report)
536        .iter()
537        .map(|p| {
538            (
539                format!("test-result/{}", p.phase.name()),
540                statement::statement(&subject, statement::TEST_RESULT, statement::test_result(p)),
541            )
542        })
543        .collect();
544    let verification = TaskVerificationV0 {
545        spec: Some(predicate::SpecRef {
546            section: t.section.map(str::to_owned),
547            section_hash: sha(t.section_text),
548        }),
549        trace: Some(json!(trace(t, report))),
550        integrity: Some(json!({ "findings": report.integrity })),
551        monitor: Some(monitor(report)),
552        mutation: Some(json!(report.mutation)),
553        sandbox: Some(t.sandbox.clone()),
554        executor: Some(t.executor.clone()),
555        model: Some(t.model.clone()),
556        run: Some(json!(report.run_id)),
557        decisions: Some(json!(decisions(&report.tool_calls))),
558        strength: Some(strength(report)),
559        instructions: Some(json!(report.instructions)),
560        red: red(report),
561        bug: t.bug.as_ref().map(|b| b.0.clone()),
562        acceptance: t.bug.as_ref().map(|b| b.1.clone()),
563        ..predicate(t, tree, policy.clone(), time.clone())
564    };
565    named.push((
566        "task-verification".to_owned(),
567        statement::statement(&subject, statement::TASK_VERIFICATION, json!(verification)),
568    ));
569    let mut lines = Vec::new();
570    let mut inputs = Vec::new();
571    for (name, s) in &named {
572        if let Err(why) = statement::check_clean(s) {
573            return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
574        }
575        let bytes = statement::canonical(s);
576        inputs.push((name.clone(), crate::integrity::sha256_hex(&bytes)));
577        lines.push(Envelope::unsigned(&bytes));
578    }
579    let summary = statement::statement(
580        &subject,
581        statement::VSA,
582        statement::vsa(&statement::Summary {
583            speccoder: env!("CARGO_PKG_VERSION").to_owned(),
584            agentplane: crate::substrate::AGENTPLANE_VERSION.to_owned(),
585            time,
586            resource_uri: resource_uri(t.repo, t.commit),
587            policy_sha256: policy,
588            inputs,
589            verdict: t.verdict.to_owned(),
590        }),
591    );
592    if let Err(why) = statement::check_clean(&summary) {
593        return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
594    }
595    lines.push(Envelope::unsigned(&statement::canonical(&summary)));
596    write(t.repo, t.commit, t.attest, lines)
597}
598
599/// Moves `from`'s statements to `to`, the commit that carries the same
600/// task's work onto the feature branch (a parallel lane's commit,
601/// cherry-picked): [`reissue`], then `from` loses its note.
602#[must_use]
603pub fn carry(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
604    if from == to {
605        // The lane started at the branch tip: the pick is the same commit.
606        return match note_state(repo, to).split_whitespace().collect::<Vec<_>>()[..] {
607            ["signed", kind, ..] => outcome(true, Some(kind), None),
608            [state, ..] => json!({ "state": state }),
609            [] => json!({ "state": "missing" }),
610        };
611    }
612    let out = reissue(repo, from, to, attest);
613    if out["state"] != "missing" {
614        notes::remove(repo, from);
615    }
616    out
617}
618
619/// Re-issues `from`'s statements for `to`, a commit with the same task's
620/// work: the subject, tree and resource move to `to`, the summary names
621/// the new lines, and everything is signed afresh. What the evidence says
622/// is unchanged.
623#[must_use]
624pub fn reissue(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
625    let Some(note) = notes::read(repo, from) else {
626        return json!({ "state": "missing", "reason": "the lane's commit had no attestation" });
627    };
628    let (envelopes, unreadable) = dsse::decode(&note);
629    let statements: Option<Vec<Value>> = envelopes.iter().map(Envelope::statement).collect();
630    let Some(mut statements) = statements.filter(|_| unreadable == 0) else {
631        return json!({ "state": "missing", "reason": "the lane's attestation was unreadable" });
632    };
633    let tree = crate::git::Git::at(repo)
634        .line(&["rev-parse", &format!("{to}^{{tree}}")])
635        .unwrap_or_default();
636    let mut renamed = std::collections::BTreeMap::new();
637    let mut lines = Vec::new();
638    for s in statements
639        .iter_mut()
640        .filter(|s| s["predicateType"] != statement::VSA)
641    {
642        let old = crate::integrity::sha256_hex(&statement::canonical(s));
643        s["subject"][0]["digest"]["gitCommit"] = json!(to);
644        if s["predicateType"] == statement::TASK_VERIFICATION {
645            s["predicate"]["tree"] = json!(tree);
646        }
647        let bytes = statement::canonical(s);
648        renamed.insert(old, crate::integrity::sha256_hex(&bytes));
649        lines.push(Envelope::unsigned(&bytes));
650    }
651    for s in statements
652        .iter_mut()
653        .filter(|s| s["predicateType"] == statement::VSA)
654    {
655        s["subject"][0]["digest"]["gitCommit"] = json!(to);
656        s["predicate"]["resourceUri"] = json!(resource_uri(repo, to));
657        if let Some(inputs) = s["predicate"]["inputAttestations"].as_array_mut() {
658            for input in inputs {
659                let old = input["digest"]["sha256"]
660                    .as_str()
661                    .unwrap_or_default()
662                    .to_owned();
663                if let Some(new) = renamed.get(&old) {
664                    input["digest"]["sha256"] = json!(new);
665                }
666            }
667        }
668        lines.push(Envelope::unsigned(&statement::canonical(s)));
669    }
670    write(repo, to, attest, lines)
671}
672
673/// Records a person's decision on a task — `attested` (accept) or
674/// `skipped` (skip) — as one `task-verification`, no summary: a decision
675/// is not a verification.
676#[must_use]
677pub fn record_decision(t: &Tick<'_>, by: &str, reason: &str) -> Value {
678    let subject = Subject {
679        name: format!("{}/{}", t.feature, t.task),
680        commit: t.commit.to_owned(),
681    };
682    let tree = crate::git::Git::at(t.repo)
683        .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
684        .unwrap_or_default();
685    let decision = TaskVerificationV0 {
686        decider: Some(Decider {
687            by: by.to_owned(),
688            reason: reason.to_owned(),
689        }),
690        // A bug's decision (kept as a regression test) anchors on its triad.
691        bug: t.bug.as_ref().map(|b| b.0.clone()),
692        acceptance: t.bug.as_ref().map(|b| b.1.clone()),
693        ..predicate(
694            t,
695            tree,
696            policy::judge_config_sha256(t.config).unwrap_or_default(),
697            now_rfc3339(),
698        )
699    };
700    let s = statement::statement(&subject, statement::TASK_VERIFICATION, json!(decision));
701    if let Err(why) = statement::check_clean(&s) {
702        return json!({ "state": "missing", "reason": format!("the statement would carry {why}; none was written") });
703    }
704    write(
705        t.repo,
706        t.commit,
707        t.attest,
708        vec![Envelope::unsigned(&statement::canonical(&s))],
709    )
710}
711
712/// `speccoder attest sign`: signs the unsigned speccoder statements of the
713/// selected task objects with the configured signer, payloads unchanged.
714#[must_use]
715pub fn sign_at(
716    path: &Path,
717    feature: Option<&str>,
718    rev: Option<&str>,
719) -> speccoder_core::CommandResult {
720    use speccoder_core::{CommandResult, CommandStatus};
721    let inspected = crate::inspect(path, feature);
722    let Some(speccoder_core::CommandData::Inspect(snap)) = inspected.data() else {
723        return CommandResult::carried("attest", &inspected);
724    };
725    let Some(f) = snap.feature.as_ref() else {
726        return CommandResult::new(
727            "attest",
728            CommandStatus::SelectionNeeded,
729            inspected.message(),
730        );
731    };
732    let root = std::path::PathBuf::from(&snap.root);
733    let config = crate::verify::find_config(&root)
734        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
735        .unwrap_or_default();
736    let attest = match crate::substrate::run_config::parse(&config) {
737        Ok(c) => c.attest,
738        Err(e) => return CommandResult::new("attest", CommandStatus::ConfigError, &e),
739    };
740    let signer = match sign::signer(&root, &attest) {
741        Ok(s) => s,
742        Err(e) => {
743            return CommandResult::new(
744                "attest",
745                CommandStatus::ConfigError,
746                &format!("cannot sign: {e}"),
747            );
748        }
749    };
750    let targets = match verify::targets(&root, &f.feature.name, rev) {
751        Ok(t) => t,
752        Err(e) => return CommandResult::new("attest", CommandStatus::GitPrecondition, &e),
753    };
754    let (mut signed, mut objects, mut failures) = (0usize, 0usize, Vec::new());
755    for t in &targets {
756        let Some(note) = notes::read(&root, &t.object) else {
757            continue;
758        };
759        let (mut lines, unreadable) = dsse::decode(&note);
760        let before = lines.iter().filter(|l| l.signatures.is_empty()).count();
761        if before == 0 || unreadable > 0 {
762            continue;
763        }
764        if let Some(e) = sign_all(&mut lines, &signer) {
765            failures.push(format!("{}: {e}", t.task));
766        }
767        let after = lines.iter().filter(|l| l.signatures.is_empty()).count();
768        if after < before {
769            if let Err(e) = notes::write(&root, &t.object, &dsse::encode(&lines)) {
770                failures.push(format!("{}: {e}", t.task));
771                continue;
772            }
773            signed += before - after;
774            objects += 1;
775        }
776    }
777    let mut message = format!(
778        "signed {signed} statement(s) on {objects} task object(s) with {}",
779        signer.kind()
780    );
781    if !failures.is_empty() {
782        message.push_str(&format!("; not signed: {}", failures.join("; ")));
783    }
784    CommandResult::new(
785        "attest",
786        if failures.is_empty() {
787            CommandStatus::Ok
788        } else {
789            CommandStatus::Error
790        },
791        &message,
792    )
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    #[test]
800    fn a_red_excerpt_is_the_clean_tail_and_withheld_when_not_clean() {
801        let long = format!("{}\nassertion failed: left == right\n", "x".repeat(4000));
802        let r = red_of("assertion", &long).unwrap();
803        assert_eq!(r.kind, predicate::RedKind::Assertion);
804        let excerpt = r.excerpt.as_deref().unwrap();
805        assert!(excerpt.len() <= predicate::RED_EXCERPT_MAX);
806        assert!(excerpt.ends_with("left == right\n"));
807        assert_eq!(r.excerpt_sha256.as_deref(), Some(sha(excerpt).as_str()));
808        assert_eq!(r.output_sha256, sha(&long));
809        assert!(!r.withheld);
810
811        let dirty = red_of("absent", "error: cannot find /Users/ada/src/lib.rs").unwrap();
812        assert_eq!(dirty.kind, predicate::RedKind::MissingSymbol);
813        assert!(dirty.withheld);
814        assert_eq!(dirty.excerpt, None);
815        assert_eq!(dirty.excerpt_sha256, None);
816        assert!(red_of("flaky", "x").is_none());
817        // A multi-byte character at the cut is never split.
818        let wide = "é".repeat(2000);
819        assert!(red_of("assertion", &wide).unwrap().excerpt.unwrap().len() <= 2048);
820    }
821
822    #[test]
823    fn the_clock_is_rfc3339_utc() {
824        let t = now_rfc3339();
825        assert_eq!(t.len(), 20, "{t}");
826        assert!(t.ends_with('Z') && t.as_bytes()[10] == b'T', "{t}");
827        assert!(t.starts_with("20"), "{t}");
828    }
829
830    #[test]
831    fn a_decision_names_who_and_through_what() {
832        let calls = vec![json!({
833            "tool": "write_file",
834            "arguments": { "path": "package.json", "content": "{}" },
835            "by": "person",
836            "approved_by": "person",
837            "scope": "once",
838            "approver": { "name": "Dana", "via": "dashboard", "basis": "asserted" },
839            "operator": { "actor": "Dana", "basis": "asserted" },
840        })];
841        let d = &decisions(&calls)[0];
842        assert_eq!(d["verb"], "approve");
843        assert_eq!(d["by"], "Dana");
844        assert_eq!(d["via"], "dashboard");
845    }
846}