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 commands the sandbox confined, run without asking.
292fn contained(tool_calls: &[Value]) -> Vec<Value> {
293    tool_calls
294        .iter()
295        .filter(|c| c["contained"] == true)
296        .map(|c| json!({ "argv": c["arguments"]["argv"] }))
297        .collect()
298}
299
300/// The acceptance set's strength as evidence: shares, counts,
301/// what got through, the permitted alternatives and the rulings — never the
302/// local patch paths or the variants' code.
303fn strength(report: &RunReport) -> Value {
304    let Some(a) = &report.strength else {
305        return Value::Null;
306    };
307    json!({
308        "visible": a.visible,
309        "combined": a.combined,
310        "held_out": a.held_out,
311        "gate": a.gate,
312        "near_misses": a.near_misses.iter().map(|n| json!({ "id": n.id, "scenario": n.scenario, "visible": n.visible, "combined": n.combined })).collect::<Vec<_>>(),
313        "permitted": a.permitted.iter().map(|p| json!({ "id": p.id, "scenario": p.scenario, "digest": p.digest, "outcome": p.outcome })).collect::<Vec<_>>(),
314        "rulings": a.rulings,
315    })
316}
317
318/// The monitor's result, `unreviewed` when it did not finish, `off` when
319/// not configured.
320fn monitor(report: &RunReport) -> Value {
321    match &report.monitor {
322        None => json!({ "state": "off", "findings": [] }),
323        Some(m) if m.get("error").is_some() => json!({ "state": "unreviewed", "findings": [] }),
324        Some(m) => json!({
325            "state": if m["suspected"] == true { "suspected" } else { "clean" },
326            "findings": m.get("findings").cloned().unwrap_or_else(|| json!([])),
327        }),
328    }
329}
330
331/// `git+<origin without credentials>@<commit>`, or `git:<commit>`.
332fn resource_uri(repo: &Path, commit: &str) -> String {
333    let origin = crate::git::Git::at(repo)
334        .line(&["remote", "get-url", "origin"])
335        .ok()
336        .filter(|u| !u.is_empty());
337    match origin {
338        Some(url) => {
339            // Credentials in a remote URL never leave the machine.
340            let clean = match url.split_once("://") {
341                Some((scheme, rest)) => {
342                    let host_end = rest.find('/').unwrap_or(rest.len());
343                    match rest[..host_end].rfind('@') {
344                        Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
345                        None => url.clone(),
346                    }
347                }
348                None => url.clone(),
349            };
350            format!("git+{clean}@{commit}")
351        }
352        None => format!("git:{commit}"),
353    }
354}
355
356/// What `object`'s note says, signatures unchecked: `signed <kind>
357/// <keyid>`, `unsigned` or `missing`.
358#[must_use]
359pub fn note_state(repo: &Path, object: &str) -> String {
360    use base64::Engine as _;
361    let Some(note) = notes::read(repo, object) else {
362        return "missing".to_owned();
363    };
364    let (lines, _) = dsse::decode(&note);
365    if lines.is_empty() {
366        return "missing".to_owned();
367    }
368    let Some(sig) = lines
369        .iter()
370        .find_map(|l| l.signatures.first().map(|s| (s, l)))
371    else {
372        return "unsigned".to_owned();
373    };
374    let armored = base64::engine::general_purpose::STANDARD
375        .decode(&sig.0.sig)
376        .map(|b| String::from_utf8_lossy(&b).into_owned())
377        .unwrap_or_default();
378    let kind = if sig.1.sigstore.is_some() {
379        "sigstore"
380    } else if armored.contains("SSH SIGNATURE") {
381        "ssh"
382    } else {
383        "openpgp"
384    };
385    format!("signed {kind} {}", sig.0.keyid)
386}
387
388/// Signs every envelope that is not signed yet; the first failure's reason
389/// when some stay unsigned.
390pub fn sign_all(lines: &mut [Envelope], signer: &sign::Signer) -> Option<String> {
391    let mut failure = None;
392    // Keyless Sigstore signs the summary alone — it names every other line
393    // by digest — so a tick costs one identity login, not one per line.
394    let summary_only = matches!(signer, sign::Signer::Sigstore)
395        && lines.iter().any(|l| {
396            l.statement()
397                .is_some_and(|s| s["predicateType"] == statement::VSA)
398        });
399    for line in lines.iter_mut().filter(|l| l.signatures.is_empty()) {
400        if summary_only
401            && !line
402                .statement()
403                .is_some_and(|s| s["predicateType"] == statement::VSA)
404        {
405            continue;
406        }
407        let Some(bytes) = line.signed_bytes() else {
408            continue;
409        };
410        match sign::sign(signer, &bytes) {
411            Ok((sig, material)) => {
412                line.signatures.push(sig);
413                line.sigstore = material;
414            }
415            Err(e) => {
416                failure.get_or_insert(e);
417            }
418        }
419    }
420    failure
421}
422
423/// Writes `lines` as `commit`'s note, signed as `[attest]` says; what it
424/// came to (`signed`/`unsigned`, signer, reason).
425fn write(repo: &Path, commit: &str, attest: &AttestConfig, mut lines: Vec<Envelope>) -> Value {
426    let (signed, signer, reason) = if attest.sign_later {
427        (
428            false,
429            None,
430            Some("[attest] sign_at = \"later\": `speccoder attest sign`".to_owned()),
431        )
432    } else {
433        match sign::signer(repo, attest) {
434            Ok(s) => match sign_all(&mut lines, &s) {
435                None => (true, Some(s.kind()), None),
436                Some(e) => (false, Some(s.kind()), Some(e)),
437            },
438            Err(e) => (false, None, Some(e)),
439        }
440    };
441    if let Err(e) = notes::write(repo, commit, &dsse::encode(&lines)) {
442        return json!({ "state": "missing", "reason": format!("writing the note failed: {e}") });
443    }
444    outcome(signed, signer, reason.as_deref())
445}
446
447/// The red run's excerpt for the predicate, when the tests went red and
448/// their output was kept (`acceptance.red_output`).
449fn red(report: &RunReport) -> Option<predicate::Red> {
450    let a = report.acceptance.as_ref().filter(|a| a["red"] == true)?;
451    red_of(a["red_class"].as_str()?, a["red_output"].as_str()?)
452}
453
454/// The last at most [`predicate::RED_EXCERPT_MAX`] bytes of `output` (on a
455/// character boundary), with its digests; withheld when not clean (a local
456/// path, a credential): only clean text is signed. `None` for a class that
457/// is not red.
458#[must_use]
459pub fn red_of(class: &str, output: &str) -> Option<predicate::Red> {
460    let kind = match class {
461        "assertion" => predicate::RedKind::Assertion,
462        "absent" => predicate::RedKind::MissingSymbol,
463        _ => return None,
464    };
465    let mut start = output.len().saturating_sub(predicate::RED_EXCERPT_MAX);
466    while !output.is_char_boundary(start) {
467        start += 1;
468    }
469    let excerpt = output[start..].trim_start_matches('\n');
470    let clean = statement::check_clean(&json!(excerpt)).is_ok();
471    Some(predicate::Red {
472        kind,
473        excerpt: clean.then(|| excerpt.to_owned()),
474        excerpt_sha256: clean.then(|| sha(excerpt)),
475        output_sha256: sha(output),
476        withheld: !clean,
477    })
478}
479
480/// The predicate's fields every statement of `t` has; the rest `None`.
481fn predicate(
482    t: &Tick<'_>,
483    tree: String,
484    policy_sha256: String,
485    time: String,
486) -> TaskVerificationV0 {
487    TaskVerificationV0 {
488        revision: Some(predicate::REVISION),
489        verdict: t.verdict.to_owned(),
490        unit: Unit {
491            format: t.format.to_owned(),
492            feature: t.feature.to_owned(),
493            dir: t.feature_dir.to_owned(),
494        },
495        task: TaskRef {
496            id: t.task.to_owned(),
497            line_hash: t.line_hash.to_owned(),
498            title: t.title.to_owned(),
499        },
500        tree,
501        spec: None,
502        trace: None,
503        integrity: None,
504        monitor: None,
505        mutation: None,
506        sandbox: None,
507        executor: None,
508        model: None,
509        run: None,
510        decisions: None,
511        contained: None,
512        strength: None,
513        instructions: None,
514        decider: None,
515        policy_sha256,
516        time,
517        red: None,
518        bug: None,
519        acceptance: None,
520        regression: None,
521        collection: None,
522        context: None,
523        dependencies: None,
524        extra: std::collections::BTreeMap::new(),
525    }
526}
527
528/// Records a passed tick: `test-result` per phase, `task-verification`,
529/// then the `vsa` naming every other line. Never fails the tick.
530#[must_use]
531pub fn record_tick(t: &Tick<'_>, report: &RunReport) -> Value {
532    let subject = Subject {
533        name: format!("{}/{}", t.feature, t.task),
534        commit: t.commit.to_owned(),
535    };
536    let tree = crate::git::Git::at(t.repo)
537        .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
538        .unwrap_or_default();
539    let policy = policy::judge_config_sha256(t.config).unwrap_or_default();
540    let time = now_rfc3339();
541    let mut named: Vec<(String, Value)> = phases(report)
542        .iter()
543        .map(|p| {
544            (
545                format!("test-result/{}", p.phase.name()),
546                statement::statement(&subject, statement::TEST_RESULT, statement::test_result(p)),
547            )
548        })
549        .collect();
550    let verification = TaskVerificationV0 {
551        spec: Some(predicate::SpecRef {
552            section: t.section.map(str::to_owned),
553            section_hash: sha(t.section_text),
554        }),
555        trace: Some(json!(trace(t, report))),
556        integrity: Some(json!({ "findings": report.integrity })),
557        monitor: Some(monitor(report)),
558        mutation: Some(json!(report.mutation)),
559        sandbox: Some(t.sandbox.clone()),
560        executor: Some(t.executor.clone()),
561        model: Some(t.model.clone()),
562        run: Some(json!(report.run_id)),
563        decisions: Some(json!(decisions(&report.tool_calls))),
564        contained: Some(json!(contained(&report.tool_calls))),
565        strength: Some(strength(report)),
566        instructions: Some(json!(report.instructions)),
567        red: red(report),
568        bug: t.bug.as_ref().map(|b| b.0.clone()),
569        acceptance: t.bug.as_ref().map(|b| b.1.clone()).or_else(|| {
570            // The acceptance files speccoder wrote (a story's: its oracle's).
571            let a = report.acceptance.as_ref()?;
572            let files = a["files"].as_array().filter(|f| !f.is_empty())?;
573            Some(json!({ "source": "authored", "files": files }))
574        }),
575        regression: report.regression.as_ref().map(|r| r.reason.clone()),
576        context: report.context.as_ref().map(|c| json!(c)),
577        collection: report.collection.clone(),
578        ..predicate(t, tree, policy.clone(), time.clone())
579    };
580    named.push((
581        "task-verification".to_owned(),
582        statement::statement(&subject, statement::TASK_VERIFICATION, json!(verification)),
583    ));
584    let mut lines = Vec::new();
585    let mut inputs = Vec::new();
586    for (name, s) in &named {
587        if let Err(why) = statement::check_clean(s) {
588            return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
589        }
590        let bytes = statement::canonical(s);
591        inputs.push((name.clone(), crate::integrity::sha256_hex(&bytes)));
592        lines.push(Envelope::unsigned(&bytes));
593    }
594    let summary = statement::statement(
595        &subject,
596        statement::VSA,
597        statement::vsa(&statement::Summary {
598            speccoder: env!("CARGO_PKG_VERSION").to_owned(),
599            agentplane: crate::substrate::AGENTPLANE_VERSION.to_owned(),
600            time,
601            resource_uri: resource_uri(t.repo, t.commit),
602            policy_sha256: policy,
603            inputs,
604            verdict: t.verdict.to_owned(),
605        }),
606    );
607    if let Err(why) = statement::check_clean(&summary) {
608        return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
609    }
610    lines.push(Envelope::unsigned(&statement::canonical(&summary)));
611    write(t.repo, t.commit, t.attest, lines)
612}
613
614/// Moves `from`'s statements to `to`, the commit that carries the same
615/// task's work onto the feature branch (a parallel lane's commit,
616/// cherry-picked): [`reissue`], then `from` loses its note.
617#[must_use]
618pub fn carry(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
619    if from == to {
620        // The lane started at the branch tip: the pick is the same commit.
621        return match note_state(repo, to).split_whitespace().collect::<Vec<_>>()[..] {
622            ["signed", kind, ..] => outcome(true, Some(kind), None),
623            [state, ..] => json!({ "state": state }),
624            [] => json!({ "state": "missing" }),
625        };
626    }
627    let out = reissue(repo, from, to, attest);
628    if out["state"] != "missing" {
629        notes::remove(repo, from);
630    }
631    out
632}
633
634/// Re-issues `from`'s statements for `to`, a commit with the same task's
635/// work: the subject, tree and resource move to `to`, the summary names
636/// the new lines, and everything is signed afresh. What the evidence says
637/// is unchanged.
638#[must_use]
639pub fn reissue(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
640    let Some(note) = notes::read(repo, from) else {
641        return json!({ "state": "missing", "reason": "the lane's commit had no attestation" });
642    };
643    let (envelopes, unreadable) = dsse::decode(&note);
644    let statements: Option<Vec<Value>> = envelopes.iter().map(Envelope::statement).collect();
645    let Some(mut statements) = statements.filter(|_| unreadable == 0) else {
646        return json!({ "state": "missing", "reason": "the lane's attestation was unreadable" });
647    };
648    let tree = crate::git::Git::at(repo)
649        .line(&["rev-parse", &format!("{to}^{{tree}}")])
650        .unwrap_or_default();
651    let mut renamed = std::collections::BTreeMap::new();
652    let mut lines = Vec::new();
653    for s in statements
654        .iter_mut()
655        .filter(|s| s["predicateType"] != statement::VSA)
656    {
657        let old = crate::integrity::sha256_hex(&statement::canonical(s));
658        s["subject"][0]["digest"]["gitCommit"] = json!(to);
659        if s["predicateType"] == statement::TASK_VERIFICATION {
660            s["predicate"]["tree"] = json!(tree);
661        }
662        let bytes = statement::canonical(s);
663        renamed.insert(old, crate::integrity::sha256_hex(&bytes));
664        lines.push(Envelope::unsigned(&bytes));
665    }
666    for s in statements
667        .iter_mut()
668        .filter(|s| s["predicateType"] == statement::VSA)
669    {
670        s["subject"][0]["digest"]["gitCommit"] = json!(to);
671        s["predicate"]["resourceUri"] = json!(resource_uri(repo, to));
672        if let Some(inputs) = s["predicate"]["inputAttestations"].as_array_mut() {
673            for input in inputs {
674                let old = input["digest"]["sha256"]
675                    .as_str()
676                    .unwrap_or_default()
677                    .to_owned();
678                if let Some(new) = renamed.get(&old) {
679                    input["digest"]["sha256"] = json!(new);
680                }
681            }
682        }
683        lines.push(Envelope::unsigned(&statement::canonical(s)));
684    }
685    write(repo, to, attest, lines)
686}
687
688/// Records a person's decision on a task — `attested` (accept) or
689/// `skipped` (skip) — as one `task-verification`, no summary: a decision
690/// is not a verification.
691#[must_use]
692pub fn record_decision(t: &Tick<'_>, by: &str, reason: &str) -> Value {
693    let subject = Subject {
694        name: format!("{}/{}", t.feature, t.task),
695        commit: t.commit.to_owned(),
696    };
697    let tree = crate::git::Git::at(t.repo)
698        .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
699        .unwrap_or_default();
700    let decision = TaskVerificationV0 {
701        decider: Some(Decider {
702            by: by.to_owned(),
703            reason: reason.to_owned(),
704        }),
705        // A bug's decision (kept as a regression test) anchors on its triad.
706        bug: t.bug.as_ref().map(|b| b.0.clone()),
707        acceptance: t.bug.as_ref().map(|b| b.1.clone()),
708        ..predicate(
709            t,
710            tree,
711            policy::judge_config_sha256(t.config).unwrap_or_default(),
712            now_rfc3339(),
713        )
714    };
715    let s = statement::statement(&subject, statement::TASK_VERIFICATION, json!(decision));
716    if let Err(why) = statement::check_clean(&s) {
717        return json!({ "state": "missing", "reason": format!("the statement would carry {why}; none was written") });
718    }
719    write(
720        t.repo,
721        t.commit,
722        t.attest,
723        vec![Envelope::unsigned(&statement::canonical(&s))],
724    )
725}
726
727/// `speccoder attest sign`: signs the unsigned speccoder statements of the
728/// selected task objects with the configured signer, payloads unchanged.
729#[must_use]
730pub fn sign_at(
731    path: &Path,
732    feature: Option<&str>,
733    rev: Option<&str>,
734) -> speccoder_core::CommandResult {
735    use speccoder_core::{CommandResult, CommandStatus};
736    let inspected = crate::inspect(path, feature);
737    let Some(speccoder_core::CommandData::Inspect(snap)) = inspected.data() else {
738        return CommandResult::carried("attest", &inspected);
739    };
740    let Some(f) = snap.feature.as_ref() else {
741        return CommandResult::new(
742            "attest",
743            CommandStatus::SelectionNeeded,
744            inspected.message(),
745        );
746    };
747    let root = std::path::PathBuf::from(&snap.root);
748    let config = crate::verify::find_config(&root)
749        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
750        .unwrap_or_default();
751    let attest = match crate::substrate::run_config::parse(&config) {
752        Ok(c) => c.attest,
753        Err(e) => return CommandResult::new("attest", CommandStatus::ConfigError, &e),
754    };
755    let signer = match sign::signer(&root, &attest) {
756        Ok(s) => s,
757        Err(e) => {
758            return CommandResult::new(
759                "attest",
760                CommandStatus::ConfigError,
761                &format!("cannot sign: {e}"),
762            );
763        }
764    };
765    let targets = match verify::targets(&root, &f.feature.name, rev) {
766        Ok(t) => t,
767        Err(e) => return CommandResult::new("attest", CommandStatus::GitPrecondition, &e),
768    };
769    let (mut signed, mut objects, mut failures) = (0usize, 0usize, Vec::new());
770    for t in &targets {
771        let Some(note) = notes::read(&root, &t.object) else {
772            continue;
773        };
774        let (mut lines, unreadable) = dsse::decode(&note);
775        let before = lines.iter().filter(|l| l.signatures.is_empty()).count();
776        if before == 0 || unreadable > 0 {
777            continue;
778        }
779        if let Some(e) = sign_all(&mut lines, &signer) {
780            failures.push(format!("{}: {e}", t.task));
781        }
782        let after = lines.iter().filter(|l| l.signatures.is_empty()).count();
783        if after < before {
784            if let Err(e) = notes::write(&root, &t.object, &dsse::encode(&lines)) {
785                failures.push(format!("{}: {e}", t.task));
786                continue;
787            }
788            signed += before - after;
789            objects += 1;
790        }
791    }
792    let mut message = format!(
793        "signed {signed} statement(s) on {objects} task object(s) with {}",
794        signer.kind()
795    );
796    if !failures.is_empty() {
797        message.push_str(&format!("; not signed: {}", failures.join("; ")));
798    }
799    CommandResult::new(
800        "attest",
801        if failures.is_empty() {
802            CommandStatus::Ok
803        } else {
804            CommandStatus::Error
805        },
806        &message,
807    )
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn a_red_excerpt_is_the_clean_tail_and_withheld_when_not_clean() {
816        let long = format!("{}\nassertion failed: left == right\n", "x".repeat(4000));
817        let r = red_of("assertion", &long).unwrap();
818        assert_eq!(r.kind, predicate::RedKind::Assertion);
819        let excerpt = r.excerpt.as_deref().unwrap();
820        assert!(excerpt.len() <= predicate::RED_EXCERPT_MAX);
821        assert!(excerpt.ends_with("left == right\n"));
822        assert_eq!(r.excerpt_sha256.as_deref(), Some(sha(excerpt).as_str()));
823        assert_eq!(r.output_sha256, sha(&long));
824        assert!(!r.withheld);
825
826        let dirty = red_of("absent", "error: cannot find /Users/ada/src/lib.rs").unwrap();
827        assert_eq!(dirty.kind, predicate::RedKind::MissingSymbol);
828        assert!(dirty.withheld);
829        assert_eq!(dirty.excerpt, None);
830        assert_eq!(dirty.excerpt_sha256, None);
831        assert!(red_of("flaky", "x").is_none());
832        // A multi-byte character at the cut is never split.
833        let wide = "é".repeat(2000);
834        assert!(red_of("assertion", &wide).unwrap().excerpt.unwrap().len() <= 2048);
835    }
836
837    #[test]
838    fn the_clock_is_rfc3339_utc() {
839        let t = now_rfc3339();
840        assert_eq!(t.len(), 20, "{t}");
841        assert!(t.ends_with('Z') && t.as_bytes()[10] == b'T', "{t}");
842        assert!(t.starts_with("20"), "{t}");
843    }
844
845    #[test]
846    fn a_decision_names_who_and_through_what() {
847        let calls = vec![json!({
848            "tool": "write_file",
849            "arguments": { "path": "package.json", "content": "{}" },
850            "by": "person",
851            "approved_by": "person",
852            "scope": "once",
853            "approver": { "name": "Dana", "via": "dashboard", "basis": "asserted" },
854            "operator": { "actor": "Dana", "basis": "asserted" },
855        })];
856        let d = &decisions(&calls)[0];
857        assert_eq!(d["verb"], "approve");
858        assert_eq!(d["by"], "Dana");
859        assert_eq!(d["via"], "dashboard");
860    }
861}