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