diff --git a/.machine_readable/bot_directives/execution_taxonomy.a2ml b/.machine_readable/bot_directives/execution_taxonomy.a2ml new file mode 100644 index 00000000..4d7219b3 --- /dev/null +++ b/.machine_readable/bot_directives/execution_taxonomy.a2ml @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# execution_taxonomy.a2ml — WHERE a run stopped, as step evidence. +# +# Read by `squabble phase` (via squabble-fight::execution) and classified by +# squabble-core::execution. It answers one question the gate model cannot: +# a red check says *that* a run failed; it never says whether the policy ran. +# +# The five outcomes an estate report must keep distinct: +# 1. not started — zero jobs: admission or source resolution refused it. +# Attributed by `squabble zero-job` / `squabble chains`. +# 2. preflight — a job started and died in runner setup, provisioning or +# lock resolution, BEFORE any policy step executed. +# 3. prerequisite — a declared tool/credential/configuration step failed, so +# the policy step could not run. +# 4. policy failed — the policy was reached and its step concluded failure. +# The FINDING is read from the policy's own report; this +# classifier only says the policy was reached. +# 5. verified — a policy step ran and succeeded (still subject to the +# green-polarity/vacuity check in squabble-core::polarity). +# +# WHY THIS FILE EXISTS AT ALL +# `squabble-core` holds no step names by design: it must stay detachable, and a +# compiled-in vocabulary would be a taxonomy rather than a classifier. The estate +# measured that lesson once already — an abbreviated scanner step name in +# gate_triage.a2ml matched no real job, so the vacuity classifier silently +# degraded to "genuine" forever (see the note in that directive). +# +# MATCHING IS EXACT (after trimming). Do not abbreviate a step name here for +# readability: an abbreviated name matches nothing and the step silently falls +# back to "policy step". Copy the `- name:` string from the workflow verbatim. +# +# HOW TO EXTEND, HONESTLY +# Add a step only when you have READ IT in a workflow this estate ships, and say +# where. A step name guessed from a convention is worse than an empty list: the +# empty list leaves the classification to the more conservative class. +# +# `Set up job` is GitHub's implicit first step in every job. It is compiled into +# squabble-core (it is a GitHub primitive, not a repo declaration), so listing it +# here is documentation, not policy. + +[metadata] +version = "0.1.0" +last-updated = "2026-10-31" +spec = "https://github.com/hyperpolymath/cicd-squabbler/blob/main/docs/reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc" +status = "wired into `squabble phase`; NOT yet wired into `squabble fight` (see proposal P-2)" + +[execution-taxonomy] +# Steps that run before the policy. A failure in one of these is PROVISIONING: +# nothing has been said about the code, the policy, or the PR. +# +# Measured 2026-10-31 in metadatastician/standards .github/workflows/ +# rust-ci-reusable.yml (jobs `check` and `test`), which is the rust CI this +# estate's consumers call: +# - name: Checkout repository +# - name: Install Rust toolchain +# - name: Install Zig for native build +# - name: Cache cargo registry and build +# and in that repository's allowlist-preflight-reusable.yml: +# - name: Stage policy tools outside the scanned tree +# `cargo check --locked` is deliberately NOT here: it is the policy step, and a +# lock-resolution error inside it is indistinguishable from a genuine compile +# failure at this evidence tier. Attribute that class from job ANNOTATIONS (the +# estate's B-LOCKFILE detector), never from a step conclusion. +setup-steps = [ + "Checkout repository", + "Checkout caller repository", + "Checkout policy source", + "Install Rust toolchain", + "Install Zig for native build", + "Cache cargo registry and build", + "Stage policy tools outside the scanned tree", +] + +# Steps that establish a DECLARED prerequisite — a tool, credential or piece of +# configuration — whose failure means the policy step could not run. +# +# Measured 2026-10-31 in metadatastician/standards +# .github/workflows/cicd-squabbler-reusable.yml (`fight-report` job): the probe +# step decides whether the supplied credential can read the whole gate before +# `squabble fight` is asked to classify anything. +# +# Kept deliberately short: an empty or thin list means class 3 is rarely +# attributed, which is the honest state of the evidence. Adding a guess here +# would turn "unknown" into "missing secret" — the exact overclaim this file's +# sibling directive warns about. +prerequisite-steps = [ + "Probe whether fight can inspect the full gate", +] + +[execution-taxonomy.notes] +# What this taxonomy deliberately does NOT do. +# +# * Class 1 needs no entry: it is "no job exists", decided before any step. +# * Billing refusal (`A-BILLING`) is a JOB ANNOTATION, not a step conclusion. +# With no step evidence the classifier reports `Unclassified (no step +# evidence)` — fail-closed — and hypatia's `scripts/ci-health/` owns that class. +# * A tag/branch-pinned `uses:` under `sha_pinning_required` refuses at startup +# with zero jobs, so it is class 1, owned by squabble-core::startup and +# `squabble zero-job`, not by this file. +# * Nothing here may be read as a verdict: no class in squabble-core::execution +# constructs a CheckRun, drops a required context, or reaches GateState::Green. diff --git a/.machine_readable/descriptiles/STATE.a2ml b/.machine_readable/descriptiles/STATE.a2ml index 2ba62a6f..1c2ccdda 100644 --- a/.machine_readable/descriptiles/STATE.a2ml +++ b/.machine_readable/descriptiles/STATE.a2ml @@ -6,7 +6,7 @@ [metadata] project = "cicd-squabbler" version = "0.1.0" -last-updated = "2026-10-28" +last-updated = "2026-10-31" status = "active" # active | paused | archived [project-context] @@ -36,6 +36,7 @@ milestones = [ { name = "`squabble chains` (Phase A, read-only) — pure cross-repo chain analysis in squabble-core::chains (cycles + dead upstreams blocking; diamonds, pin skew, tag/branch pins warnings; landing order; max-plus longest chain), local + GitHub GraphQL sources (new squabble-forge crate, queries validated against a committed schema snapshot), `--follow N` upstream probing, exit 4 on blocking. Dogfooded offline on cicd-squabbler, standards, typed-wasm, rsr-template-repo; live GraphQL run pending a gh-authenticated host.", completion = 90 }, { name = "Zero-job `startup_failure` attribution — a run that dies before any job exists creates no check run, so nothing shows on the PR. Two first-class classes with DISTINCT moves: moved coordinate (reusable owner renamed/transferred → `RepointToCanonical`, same pin) and allow-list admission required (canonical coordinate refused by the caller's `selected` allow-list → `AllowListAdmissionRequired`, exact `owner/repo@*`). `squabble zero-job ` reports per-run attribution (repo, workflow, coordinate as written, canonical target, required pattern) and exits 4 on any observed run; `squabble chains` resolves every upstream's canonical `full_name` through REST (names compared, never pins) to flag moved upstreams and canonical-grouped pin skew before a run proves it. Diagnosis only — no CheckRun is constructed, no postures edited, no green declared. Code complete; build/tests and a live `gh`-authenticated run pending a Rust toolchain (none installable in the authoring environment).", completion = 70 }, { name = "Creusot complement for Rust (the twin of spark/): verification/squabble-creusot states the engine's load-bearing invariants as Creusot contracts with a stable-Rust mirror that tests the model against the real squabble-core; own workspace + rust-toolchain pin (nightly-2026-08-03 for Creusot v0.14.0) so the nightly-only LGPL proof library never enters the stable --locked build; estate gate wired via hyperpolymath/cicd-suite's creusot-gate.yml (enforce_hollow), plus scripts/install-toolchain.sh and just verify-{status,install,mirror,creusot,gate}. STATED, NOT PROVED: no obligation has been discharged; detect/theatre phases run clean on this tree, prove phase fails closed. Dev container now installs the Rust toolchains it was missing.", completion = 35 }, + { name = "Execution-phase classification (estate CI diagnosis coverage) — squabble-core::execution distinguishes the five outcomes a report must not collapse: not started (deferred to startup/zero-job), preflight (runner setup/provisioning/lock, BEFORE any policy step), prerequisite missing (declared tool/credential/config), policy step failed (the policy was reached — the finding is read from its own report, never inferred), verified (job passed AND a policy step recorded success). Host-supplied step signatures in .machine_readable/bot_directives/execution_taxonomy.a2ml (no step name compiled in; exact match); fail-closed Unclassified with five reasons; Handoff names the decision owner. No CheckRun constructed, no Move proposed, no gate state reachable. Offline entry point `squabble phase `. WIRED OFFLINE ONLY: `fight` does not yet fetch step evidence for red checks, so a preflight failure is still attributed by workflow shape in the planner — that is proposal P-1/P-2 in docs/reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc. Tests written, NOT compiled or executed (no Rust toolchain in the authoring environment).", completion = 60 }, { name = "RSR scaffolding customisation (identity, root-allow, A2ML manifests)", completion = 80 }, ] @@ -45,6 +46,7 @@ milestones = [ [critical-next-actions] actions = [ + "Compile and run the execution-phase suite on a host with a Rust toolchain (`just test`), then wire it into `fight` (proposals P-1/P-2 in docs/reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc): fetch job step evidence for FAILED checks (fetch.rs already parses job ids out of check details_url but keeps greens only) and route a preflight/prerequisite failure to the provisioning owner instead of EscalateToExpert{DispatchFix}. Until then a setup failure is still attributed by workflow shape in the planner.", "Owner: decide the REUSE/dep5 declaration for crates/squabble-forge/graphql/github-schema.graphql (third-party GitHub schema; the Files: * default would mis-declare it MPL-2.0) — or drop the snapshot and fetch it in CI.", "Live-fire `squabble chains gh:… --follow 1` on a gh-authenticated host; then fetch reusable upstreams at their PINNED SHA (GraphQL object(expression:\":.github/workflows\")) so diamonds carry basis=pinned-revision instead of scanned-revision.", "Enact the remaining self-wins under --apply: required-context reconcile and reusable-workflow re-pin both need the GitHub ruleset API / a target SHA (network), so they stay propose-only until that host capability exists. (`--apply` already enacts the path-filter strip, which is a pure local file edit.)", diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index ec1759e2..dd311371 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,66 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. ==== Added +* `scripts/local-verify.sh` (and `just local-verify`) — runs every gate CI runs, + locally, and leaves one bundle (`SUMMARY.txt` inside a timestamped `$TMPDIR` + directory) carrying per-step status, the errors extracted from each log, the + toolchain identity and the before/after worktree state. It writes nothing into + the repository, and it delegates installation to `scripts/install-toolchain.sh` + so nothing it installs is unverified. Status vocabulary is the estate's: + `PASS`/`FAIL`/`VOID`/`SKIP`/`KNOWN`, where `VOID` ("no check was performed") + and `SKIP` are never passes, and a `KNOWN` pre-existing failure must be the + single failure in the run to be recognised at all. `--only ` runs a subset + (deselected steps are recorded `SKIP`, never `PASS`) and `--check` reports the + toolchain and the plan without running a step or writing a ledger. Written + because the new modules below were authored without a reachable Rust toolchain. + They type-check on CI but have not been executed: `Cargo fmt` fails first, and + the `Cargo test` job does not start. That failure is **inherited**: `main` is + not fmt-clean, and the 7 unformatted workspace files it carries are repaired by + #144 (and identically by #143). None of the files this change adds or modifies + is unformatted. Merge one of those two first, rebase, and `fmt` passes on its + own — only then do `clippy` and the tests execute. +* `tests/local_verify_test.sh` — 31 planted controls for the harness above: no + toolchain (`VOID`, exit 2), all-green (exit 0), a planted compile error (its + diagnostic and `file:line` extracted), a fmt diff, a failing assertion (both + sides extracted), `--only`, `--check`, a missing `jq` (`VOID`, never `PASS`), an + unchanged worktree, and exactly one documented status per ledger row. No Rust, + no network, no token: `bash tests/local_verify_test.sh`. + Two of its own first-draft cases were **wrong and were fixed, not worked + around**: the bare `PATH` fixture omitted `dirname`, so the script under test + resolved its repository root to `/` and two `VOID` cases passed for the wrong + reason; and one case piped the script into `grep -q`, which exits at the first + match and killed the writer with `SIGPIPE`, which `pipefail` then reported as + the plan being missing. +* `squabble_core::execution` — the execution-phase classifier. `fight` knew only + *that* a check was red, so a job that died in runner setup or lock resolution + was attributed by the workflow's *shape* (a code fixer, a security scan, an + upstream owner) rather than as provisioning. The module classifies step + evidence into the five outcomes an estate report must keep distinct — not + started, preflight, prerequisite missing, policy step failed, verified — with + a fail-closed `Unclassified` carrying one of five reasons, and a `Handoff` + naming who must act. Signatures are **host-supplied** (no step name is + compiled in, preserving the standalone rule) and matched **exactly**, the + lesson `gate_triage.a2ml` 0.2.0 already paid for. It constructs no `CheckRun`, + proposes no `Move`, drops no required context and reaches no gate state, so + `squabble ≠ bypass` is untouched; `Verified` hands off to `NoClaim` and must + still be read with `polarity`. +* `squabble phase [--repo ] [--json]` — the offline entry point + for that classifier, over a jobs-API payload. Prints class, step, handoff + label, owner phrase and explanation, plus a permanent "NOT a gate verdict" + line. Exit `0` (a classification was produced) or `2` (tool failure): there is + deliberately no exit code that means green. Outside `squabble-contract/1.0.0`, + like `zero-job` and `chains`. +* `.machine_readable/bot_directives/execution_taxonomy.a2ml` — the step-name + signatures, each with the workflow it was read from (2026-10-31), and an + explicit note that `cargo check --locked` is *not* a setup step: a + dependency-resolution error inside it is indistinguishable from a compile + failure at the step-conclusion evidence tier. +* `docs/reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc` — the + role/ownership map behind the above: the five outcomes, who diagnoses and who + remediates each (hypatia `ci-health`, standards' reusables, cicd-suite, + reposystem, proven-tests-and-benches, gitbot-fleet), eight concrete gaps, the + owner-handoff table for inaccessible settings, and six proposals kept separate + from what is implemented. * `.github/actions/squabble` — a composite action that installs the squabble v0.1.0 release binary for other workflows and, optionally, runs it. It refuses any download whose sha256 is not the pinned digest, or whose @@ -98,9 +158,11 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. verify-{status,install,mirror,creusot,gate}` install and run the toolchain, Creusot included via upstream's `./INSTALL` (not `cargo install`). **Stated, not proved**: no obligation has been discharged — the authoring - environment has no Rust toolchain at all, and this repository's `Rust CI` - currently ends as a zero-job `startup_failure`, so CI has not compiled the - workspace either. The estate gate's detect/theatre phases were run against + environment has no Rust toolchain at all, and this repository's `Rust CI` on + `main` has been ending as a zero-job `startup_failure` (four consecutive runs), + so pushes to `main` have not compiled the workspace. Pull requests *do* run it: + on PR #145 `Cargo check` passes and `Cargo fmt` fails, so the workspace is + type-checked but the Creusot prove job still has no green precedent. The estate gate's detect/theatre phases were run against this tree (obligations=true, claims=false, 10 contract attributes, T1/T2/T3 clean) and its prove phase correctly reported "NO PROOF WAS PERFORMED". * The dev container now installs the Rust toolchains it was missing (stable for diff --git a/Justfile b/Justfile index d2f5e56c..e27c3e4a 100644 --- a/Justfile +++ b/Justfile @@ -360,6 +360,15 @@ validate-coapt: # ═══════════════════════════════════════════════════════════════════════════════ # Report which toolchains and provers are present (installs nothing) +# Run every CI gate locally and leave one evidence bundle to hand back. +# Writes nothing into the repository (default bundle: $TMPDIR/squabble-verify-*). +local-verify *args: + @bash scripts/local-verify.sh {{args}} + +# Planted controls for the harness above (no Rust, no network, no token) +local-verify-test: + @bash tests/local_verify_test.sh + verify-status: @bash scripts/install-toolchain.sh --check diff --git a/README.adoc b/README.adoc index 905ce999..ba2387dd 100644 --- a/README.adoc +++ b/README.adoc @@ -120,6 +120,32 @@ Both moves only let a check *start*. Neither drops a required context, weakens a check, nor edits settings: `squabble ≠ bypass` holds, and nothing here is a green. A missing or skipped check cannot be made green by a diagnosis. +== Where a run stopped: preflight is not a finding + +A red check says *that* a run failed; it never says whether the policy ran. A job +that dies in runner setup, provisioning or lock resolution is not a verdict on +the code, and attributing it by the workflow's shape dispatches work nobody can +do. + +[source,sh] +---- +gh api repos/o/r/actions/jobs/12345 > job.json +squabble phase job.json --repo . # class · step · handoff · owner +---- + +`squabble phase` classifies step evidence into the outcomes an estate report must +keep distinct — *not started* (zero jobs: attributed by `zero-job`/`chains`), +*preflight*, *prerequisite missing*, *policy step failed* (where, never why: read +the policy's own report), and *verified* (still subject to `polarity`'s vacuity +check) — or refuses with a reason (`Unclassified`). Step names live in +`.machine_readable/bot_directives/execution_taxonomy.a2ml`, never in the engine. +No class constructs a check, drops a context or reaches green: `squabble ≠ +bypass` holds, and `phase` has no exit code that means green. + +The role, ownership map, gaps and handoffs across `cicd-suite`, `standards`, +`reposystem`, `proven-tests-and-benches`, Hypatia and `gitbot-fleet`: +`docs/reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc`. + == Rust deductive verification (Creusot) The estate requires its Rust to carry verification obligations, the twin of the diff --git a/crates/squabble-cli/src/main.rs b/crates/squabble-cli/src/main.rs index d9292b42..93b245ca 100644 --- a/crates/squabble-cli/src/main.rs +++ b/crates/squabble-cli/src/main.rs @@ -35,6 +35,11 @@ //! //! Note `3` says nothing about *classic* branch protection, which lives behind //! a different endpoint and is invisible to the rules API this binary queries. +//! +//! `squabble phase` uses only `0` (a classification was produced) and `2` (tool +//! failure). There is deliberately no exit code that means "green": it reports +//! *where* a job stopped — preflight, prerequisite, policy-reached, verified — +//! which is a diagnosis, not a gate verdict. mod board; #[cfg(feature = "boj")] @@ -43,6 +48,7 @@ mod chains; mod fetch; mod fight; mod inbox; +mod phase; mod verify; mod zero_job; @@ -71,6 +77,7 @@ fn main() -> ExitCode { Some("fight") => fight::run(&args[2..]), Some("chains") => chains::run(&args[2..]), Some("zero-job") => zero_job::run(&args[2..]), + Some("phase") => phase::run(&args[2..]), Some("verify-satisfied") => verify::run(&args[2..]), Some("board") => board::run(&args[2..]), Some("inbox-sweep") => inbox::run(&args[2..]), @@ -92,6 +99,7 @@ fn main() -> ExitCode { squabble {}\n \ squabble {}\n \ squabble {}\n \ + squabble {}\n \ squabble --version\n\n\ EXIT CODES:\n \ 0 ok · 2 failure · 3 no `required_status_checks` rule on the base branch · 4 chains/zero-job: blocking finding · 5 verify-satisfied: agent items remain · 6 board or inbox-sweep: incomplete\n", @@ -99,6 +107,7 @@ fn main() -> ExitCode { fight::USAGE.trim_start_matches("usage: squabble "), chains::USAGE.trim_start_matches("usage: squabble "), zero_job::USAGE.trim_start_matches("usage: squabble "), + phase::USAGE.trim_start_matches("usage: squabble "), board::USAGE.trim_start_matches("usage: squabble "), inbox::USAGE.trim_start_matches("usage: squabble "), verify::USAGE.trim_start_matches("usage: squabble ") diff --git a/crates/squabble-cli/src/phase.rs b/crates/squabble-cli/src/phase.rs new file mode 100644 index 00000000..e05ea60d --- /dev/null +++ b/crates/squabble-cli/src/phase.rs @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +//! `squabble phase` — classify **where** one Actions job stopped. +//! +//! A red check says *that* a run failed. It never says whether the policy ran, +//! and the estate's investigation separates five outcomes that all wear the same +//! word: +//! +//! 1. the workflow never started (zero jobs) — attributed by `squabble zero-job`, +//! not by this command; +//! 2. the job died in runner setup / provisioning / lock resolution; +//! 3. a declared tool, credential or configuration prerequisite was missing; +//! 4. the policy executed and concluded failure; +//! 5. the policy executed and succeeded. +//! +//! This command reads a jobs-API payload (one job object, as +//! `GET /repos/{owner}/{repo}/actions/jobs/{job_id}` returns it) plus the +//! repository's `execution_taxonomy.a2ml` directive, and prints the class, the +//! step it is about, and the handoff. `--json` prints the class alone. +//! +//! ```text +//! gh api repos/o/r/actions/jobs/12345 > job.json +//! squabble phase job.json --repo . +//! ``` +//! +//! # What this command refuses to say +//! +//! It is a diagnosis, not a verdict. There is deliberately **no exit code that +//! means green**: `0` says a classification was produced (including +//! `policy-step-failed`), `2` is a tool failure. Nothing here edits a workflow, +//! edits a setting, re-runs a job, or drops a required context, and a +//! `verified` class still needs `polarity` (vacuity) and the gate model before +//! anyone may call a repository green. + +use squabble_core::execution::{classify, ExecutionClass}; +use squabble_core::gate::CheckRun; +use squabble_core::polarity::{StepConclusion, StepOutcome}; +use squabble_fight::execution::load_signatures; +use std::path::Path; +use std::process::ExitCode; + +pub(crate) const USAGE: &str = "usage: squabble phase [--repo ] [--json]"; + +struct Args { + job: String, + repo: String, + json: bool, +} + +/// Parse one jobs-API payload path, an optional `--repo` checkout (default `.`, +/// where the directive is read from), and `--json`. +fn parse_args(rest: &[String]) -> Result { + let mut a = Args { + job: String::new(), + repo: ".".to_string(), + json: false, + }; + let mut it = rest.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--json" => a.json = true, + "--repo" => { + a.repo = it + .next() + .ok_or_else(|| format!("--repo needs a path\n{USAGE}"))? + .clone() + } + s if s.starts_with("--") => return Err(format!("unknown flag `{s}`\n{USAGE}")), + s => { + if !a.job.is_empty() { + return Err(format!("only one may be given\n{USAGE}")); + } + a.job = s.to_string(); + } + } + } + if a.job.is_empty() { + return Err(USAGE.to_string()); + } + Ok(a) +} + +/// Read the job conclusion and step list out of a jobs-API payload. +/// +/// A `null` payload or an empty object means *no job was observed* — the +/// zero-job class, which is `squabble zero-job`'s work and is reported here as +/// such rather than guessed at. An absent `steps` array is a legitimate payload +/// and parses to an empty list (which the classifier treats as absence of +/// evidence, never as evidence of anything). +/// A non-array `steps` value also yields an empty list. Job status and conclusion +/// are passed unchanged to `CheckRun::from_github`, which recognises uppercase +/// values; unrecognised values can result in a pending job. +/// +/// Returns an error for invalid JSON, a non-object value other than null, or a +/// non-empty object without a string `status` or `conclusion`. +fn parse_job(text: &str) -> Result<(Option, Vec), String> { + let v: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?; + + if let serde_json::Value::Object(o) = &v { + let is_job = !o.is_empty() + && (o.get("status").map_or(false, |s| s.is_string()) + || o.get("conclusion").map_or(false, |s| s.is_string())); + if !o.is_empty() && !is_job { + return Err("not a jobs-API job object (no `status` or `conclusion`)".to_string()); + } + } else if !v.is_null() { + return Err("expected a JSON object or null".to_string()); + } + + let no_job = match &v { + serde_json::Value::Null => true, + serde_json::Value::Object(o) => o.is_empty(), + _ => false, + }; + let job = if no_job { + None + } else { + let upper = |k: &str| { + v.get(k) + .and_then(|s| s.as_str()) + .map(str::to_ascii_uppercase) + }; + let (status, conclusion) = (upper("status"), upper("conclusion")); + Some(CheckRun::from_github( + status.as_deref(), + conclusion.as_deref(), + )) + }; + + let steps = v + .get("steps") + .and_then(|s| s.as_array()) + .map(|arr| { + arr.iter() + .map(|s| { + StepOutcome::new( + s.get("name") + .and_then(|n| n.as_str()) + .unwrap_or_default() + .to_string(), + StepConclusion::parse(s.get("conclusion").and_then(|c| c.as_str())), + ) + }) + .collect() + }) + .unwrap_or_default(); + + Ok((job, steps)) +} + +/// Read and classify a job payload, printing the result as text or JSON. +/// Returns 2 for invalid arguments, read or parse errors, or JSON serialisation +/// failure; a missing or unreadable directive falls back to empty signatures. +/// Returns 0 when a classification is printed, including an unclassified result. +pub(crate) fn run(rest: &[String]) -> ExitCode { + let args = match parse_args(rest) { + Ok(a) => a, + Err(e) => { + eprintln!("squabble phase: {e}"); + return ExitCode::from(2); + } + }; + + let text = match std::fs::read_to_string(&args.job) { + Ok(t) => t, + Err(e) => { + eprintln!("squabble phase: cannot read `{}`: {e}", args.job); + return ExitCode::from(2); + } + }; + let (job, steps) = match parse_job(&text) { + Ok(p) => p, + Err(e) => { + eprintln!("squabble phase: `{}`: {e}", args.job); + return ExitCode::from(2); + } + }; + + let signatures = load_signatures(Path::new(&args.repo)); + let class: ExecutionClass = classify(job, &steps, &signatures); + + if args.json { + match serde_json::to_string_pretty(&class) { + Ok(json) => { + println!("{json}"); + return ExitCode::SUCCESS; + } + Err(e) => { + eprintln!("squabble phase: could not serialise the class: {e}"); + return ExitCode::from(2); + } + } + } + + println!("repo: {}", args.repo); + println!( + "job: {}", + match job { + None => "none observed (no job — the zero-job class)".to_string(), + Some(c) => format!("{c:?}"), + } + ); + println!("steps: {}", steps.len()); + println!("class: {}", class.label()); + if let Some(step) = class.step() { + println!("step: {step}"); + } + println!("handoff: {}", class.handoff().label()); + println!("owner: {}", class.handoff().owner()); + println!("explains: {}", class.explain()); + // Stated on every run, so no reader mistakes a classification for a verdict. + println!( + "\nNOT a gate verdict: this classifies where the job stopped. It asserts no gate state, \ + proposes no move, and nothing here is a green." + ); + if !signatures.is_usable() { + println!( + "note: no usable execution_taxonomy signatures under `{}` — preflight and \ + prerequisite classes can only be attributed from GitHub's implicit `Set up job`.", + args.repo + ); + } + + ExitCode::SUCCESS +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(v: &[&str]) -> Vec { + v.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn positional_and_flags_parse() { + let a = parse_args(&args(&["job.json", "--repo", "..", "--json"])).unwrap(); + assert_eq!(a.job, "job.json"); + assert_eq!(a.repo, ".."); + assert!(a.json); + // Defaults. + let b = parse_args(&args(&["job.json"])).unwrap(); + assert_eq!(b.repo, "."); + assert!(!b.json); + } + + #[test] + fn bad_usage_is_refused() { + assert!(parse_args(&args(&[])).is_err()); + assert!(parse_args(&args(&["--repo"])).is_err()); + assert!(parse_args(&args(&["a.json", "b.json"])).is_err()); + assert!(parse_args(&args(&["--wat", "a.json"])).is_err()); + } + + #[test] + fn a_failed_jobs_payload_parses_its_steps() { + let json = r#"{ + "id": 42, + "status": "completed", + "conclusion": "failure", + "steps": [ + {"name": "Set up job", "conclusion": "success"}, + {"name": "Checkout repository", "conclusion": "success"}, + {"name": "Cargo test", "conclusion": "failure"}, + {"name": "Post job", "conclusion": null} + ] + }"#; + let (job, steps) = parse_job(json).unwrap(); + assert_eq!(job, Some(CheckRun::Failed)); + assert_eq!(steps.len(), 4); + assert_eq!(steps[2].conclusion, StepConclusion::Failure); + // A null conclusion is `Other`, never a skip. + assert_eq!(steps[3].conclusion, StepConclusion::Other); + } + + #[test] + fn a_null_or_empty_payload_is_the_no_job_class() { + assert_eq!(parse_job("null").unwrap().0, None); + assert_eq!(parse_job("{}").unwrap().0, None); + // Classified as class 1, which defers attribution to zero-job. + let (job, steps) = parse_job("null").unwrap(); + assert_eq!( + classify(job, &steps, &Default::default()), + ExecutionClass::NotStarted + ); + } + + #[test] + fn a_payload_without_steps_is_absence_of_evidence_not_a_class() { + let (job, steps) = + parse_job(r#"{"status": "completed", "conclusion": "failure"}"#).unwrap(); + assert_eq!(job, Some(CheckRun::Failed)); + assert!(steps.is_empty()); + let c = classify(job, &steps, &Default::default()); + assert_eq!(c.label(), "unclassified"); + } + + #[test] + fn lowercase_values_are_normalised_before_classification() { + // GitHub's API reports lowercase `status`/`conclusion`, but + // `CheckRun::from_github` matches uppercase. Passing them unchanged made + // a real failure read as Pending — a red classified as "not concluded". + // This test exists because that bug was live until the job payload was + // normalised, and nothing executed to catch it. + let lower = parse_job(r#"{"status": "completed", "conclusion": "failure"}"#) + .unwrap() + .0; + assert_eq!(lower, Some(CheckRun::Failed)); + let upper = parse_job(r#"{"status": "COMPLETED", "conclusion": "FAILURE"}"#) + .unwrap() + .0; + assert_eq!(upper, lower, "case must not change the conclusion"); + let skipped = parse_job(r#"{"status": "completed", "conclusion": "skipped"}"#) + .unwrap() + .0; + assert_eq!(skipped, Some(CheckRun::Skipped)); + // Still running: no conclusion, so not concluded — never a failure. + let running = parse_job(r#"{"status": "in_progress"}"#).unwrap().0; + assert_eq!(running, Some(CheckRun::Pending)); + } + + #[test] + fn an_object_that_is_not_a_job_is_a_tool_failure() { + // The `gh api …/jobs` wrapper and any other object would otherwise be + // read as a Pending job, i.e. "no evidence" silently became "not + // concluded" instead of a refusal. + assert!(parse_job(r#"{"total_count": 1, "jobs": []}"#).is_err()); + assert!(parse_job(r#"{"steps": []}"#).is_err()); + // A non-string status is not a status. + assert!(parse_job(r#"{"status": 5}"#).is_err()); + } + + #[test] + fn a_json_array_is_a_tool_failure_not_a_job() { + assert!(parse_job("[]").is_err()); + assert!(parse_job(r#"[{"status": "completed"}]"#).is_err()); + assert!(parse_job("42").is_err()); + assert!(parse_job(r#""completed""#).is_err()); + } + + #[test] + fn a_non_json_payload_is_a_tool_failure() { + assert!(parse_job("nope").is_err()); + } +} diff --git a/crates/squabble-core/src/execution.rs b/crates/squabble-core/src/execution.rs new file mode 100644 index 00000000..d2f386fa --- /dev/null +++ b/crates/squabble-core/src/execution.rs @@ -0,0 +1,785 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +//! The execution-phase classifier — *where* a run stopped, not merely that it +//! stopped. +//! +//! `fight` learns one bit about a red check: that it is red. It then attributes +//! the red from the *shape of the workflow* (`squabble-fight::workflows`): an +//! in-repo hygiene workflow is a self-win, a security workflow is a security +//! escalation, a reusable is an upstream owner. That attribution is only sound +//! when the policy actually ran. A job that died in runner setup, or that never +//! got its toolchain, is not a finding about the code — and handing it to a +//! fixer invents work nobody can do. +//! +//! The estate's own investigation separates five outcomes that share the single +//! word "red": +//! +//! 1. **the workflow never started** — admission or source resolution refused +//! it, zero jobs ran. Owned by [`crate::startup`] and `squabble zero-job`; +//! this module does not re-derive it, it only *names* it +//! ([`ExecutionClass::NotStarted`]) so a report cannot silently drop it. +//! 2. **preflight** — the job started and died in runner setup, provisioning or +//! lock resolution, before any policy step executed. +//! 3. **prerequisite missing** — a declared step that establishes a tool, +//! credential or piece of configuration failed, so the policy step could not +//! run. +//! 4. **the policy executed and failed** — findings, or a policy script that +//! returned non-zero. +//! 5. **the policy executed and succeeded** — the only evidence-bearing success. +//! +//! Classes 2–5 are witnessable from the one source `fight` already reads for +//! greens: the Actions jobs API step conclusions +//! (`.steps[].{name,conclusion}`, the same source +//! [`crate::polarity`] uses). +//! +//! # Evidence tier — stated, not implied +//! +//! Step *names and conclusions* are the whole input. So: +//! +//! * [`ExecutionClass::PreflightFailed`] is only reachable for a step the +//! **host declares** to be preflight (or GitHub's implicit `Set up job`). An +//! undeclared setup step is not guessed at. +//! * [`ExecutionClass::PolicyStepFailed`] says *where* the run stopped, never +//! *why*. A dependency-resolution error inside a step like `Cargo check +//! --locked` is indistinguishable here from a genuine compile failure, +//! because both are "a policy step concluded failure". This module therefore +//! does **not** assert class 4; it reports that the policy was reached, and +//! the finding itself must be read from the policy's own report. The +//! estate's lockfile class (`B-LOCKFILE`) is detected by reading **job +//! annotations**, which is a stronger source this module deliberately does +//! not have. +//! +//! # Fail-closed +//! +//! No step evidence, an unconcluded job, a failure outside any step, or a green +//! with no successful step all yield [`ExecutionClass::Unclassified`] with a +//! reason. An unreadable fact never becomes a class. +//! +//! # Nothing here is a green +//! +//! [`ExecutionClass::Verified`] is a statement that a step ran and succeeded — +//! the same fact [`crate::polarity`] interrogates for vacuity, and the two must +//! be read together. This module constructs no [`CheckRun`], calls no +//! [`crate::gate::Gate::evaluate`], drops no required context and proposes no +//! [`crate::moves::Move`], so `squabble ≠ bypass` holds bit-for-bit: a +//! classification is a diagnosis, and no diagnosis moves a gate. + +use crate::gate::CheckRun; +use crate::polarity::{StepConclusion, StepOutcome}; +use serde::{Deserialize, Serialize}; + +/// The implicit step GitHub inserts at the head of every job. Its failure is +/// the runner-side class (image pull, runner allocation, billing refusal) and +/// it is universal, so it is compiled in rather than declared per repo. This is +/// a GitHub primitive, not a scanner name: the standalone rule +/// (`hypatia-dependent` is an IS-NOT) is not bent by it. +pub const SETUP_JOB_STEP: &str = "Set up job"; + +/// GitHub's trailing cleanup steps. A failure here happens *after* the policy +/// and is not a policy finding, so it must not be read as one. +pub const POST_JOB_STEPS: &[&str] = &["Post job", "Complete job"]; + +/// Host-supplied step-name signatures, read from the `execution_taxonomy` +/// directive. **No step name of any estate tool is compiled in** — the same +/// discipline as [`crate::polarity::VacuitySignature`], for the same reason: a +/// classifier that carries other people's vocabulary is a taxonomy, not a +/// classifier, and it drifts silently when a workflow is renamed. +/// +/// Matching is an EXACT compare after trimming, deliberately. This repository +/// has already paid once for abbreviation drift (see `gate_triage.a2ml`: an +/// abbreviated step name matched no real job on earth, and the vacuity +/// classifier silently became its own fake green). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PhaseSignatures { + /// Steps that run **before** the policy: runner/service setup, checkout, + /// toolchain installation, dependency and lock resolution, cache restore. + #[serde(default)] + pub setup_steps: Vec, + /// Steps that establish a *declared* prerequisite — a tool, credential or + /// configuration fact — whose failure means the policy step cannot run. + #[serde(default)] + pub prerequisite_steps: Vec, +} + +impl PhaseSignatures { + /// A signature set is usable only when it declares something. An empty set + /// adds no matches, though GitHub's implicit setup step still matches. + pub fn is_usable(&self) -> bool { + !self.setup_steps.is_empty() || !self.prerequisite_steps.is_empty() + } + + /// Is this step a declared setup step (or GitHub's implicit one)? + pub fn is_setup(&self, name: &str) -> bool { + let n = name.trim(); + n == SETUP_JOB_STEP || self.setup_steps.iter().any(|s| s.trim() == n) + } + + /// Is this step a declared prerequisite step? + pub fn is_prerequisite(&self, name: &str) -> bool { + let n = name.trim(); + self.prerequisite_steps.iter().any(|s| s.trim() == n) + } + + /// GitHub's own trailing cleanup steps — never a policy step. + pub fn is_post(&self, name: &str) -> bool { + let n = name.trim(); + POST_JOB_STEPS.iter().any(|s| *s == n) + } + + /// A step that is not preflight, not a prerequisite and not cleanup: the + /// only kind whose conclusion is evidence about the policy itself. + pub fn is_policy_step(&self, name: &str) -> bool { + !self.is_setup(name) && !self.is_prerequisite(name) && !self.is_post(name) + } +} + +/// Why a run could not be placed in one of the five classes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum UnclassifiedReason { + /// The job exists but records no steps at all — absence of evidence, not + /// evidence of anything (the same stance [`crate::polarity`] takes for a + /// green with no steps). + NoStepEvidence, + /// The job has not concluded (queued, in progress). + NotConcluded, + /// The conclusion says the job failed, but no step recorded a failure: + /// cancelled, timed out, or a runner-side failure between steps. + NoFailingStep, + /// The only failed step is GitHub's trailing cleanup, which runs after the + /// policy — it says nothing about the policy. + FailureOutsidePolicyStep, + /// The job concluded successfully but no policy step recorded success, so + /// there is no evidence the policy executed. (`polarity` calls the + /// all-steps-skipped form vacuous; this module refuses to call it verified.) + NoSuccessfulStep, +} + +impl UnclassifiedReason { + pub const fn label(self) -> &'static str { + match self { + Self::NoStepEvidence => "no step evidence", + Self::NotConcluded => "not concluded", + Self::NoFailingStep => "no failing step", + Self::FailureOutsidePolicyStep => "failure outside a policy step", + Self::NoSuccessfulStep => "no successful policy step", + } + } +} + +/// Who must act on a class, within the squabbler's lane rules. These name a +/// **decision owner**, never a fixer the squabbler may dispatch on its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Handoff { + /// Nothing ran: hand to admission/source-resolution attribution + /// (`crate::startup`, `squabble zero-job`, `squabble chains`) and to the + /// owner of the refusing setting. + Admission, + /// Runner, provisioning or lock resolution. The owner of the *workflow's + /// provisioning*, not a code fixer: nothing has been said about the code. + Provisioning, + /// A declared tool/credential/configuration prerequisite. The owner of the + /// repository's settings or secrets. + Prerequisites, + /// The policy was reached. Read its own report (annotation, SARIF, log) — + /// the finding is evidence to be collected, not an inference to be made. + PolicyReport, + /// No claim. The report must say so rather than pick a lane. + NoClaim, +} + +impl Handoff { + pub const fn label(self) -> &'static str { + match self { + Self::Admission => "admission", + Self::Provisioning => "provisioning", + Self::Prerequisites => "prerequisites", + Self::PolicyReport => "policy-report", + Self::NoClaim => "no-claim", + } + } + + /// Who owns the next move, in plain words. Deliberately a human/owner + /// phrase: the squabbler proposes, an owner acts. + pub const fn owner(self) -> &'static str { + match self { + Self::Admission => + "owner of the refusing setting (Actions policy / allow-list / ruleset), with `squabble zero-job` for the attribution", + Self::Provisioning => + "owner of the workflow's provisioning (runner label, toolchain, dependency and lock resolution) — no claim is made about the code", + Self::Prerequisites => + "owner of the repository's tools/credentials/configuration", + Self::PolicyReport => + "the producing policy's owner; collect its report as evidence", + Self::NoClaim => + "nobody yet: the evidence does not support a class, and the run still must be explained", + } + } +} + +/// Where a job stopped, as one of the estate's distinct outcomes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "class", rename_all = "kebab-case")] +pub enum ExecutionClass { + /// Class 1 — no job exists at all (zero-job `startup_failure`, admission or + /// source-resolution refusal). Attribution lives in [`crate::startup`]. + NotStarted, + /// Class 2 — a declared preflight step failed. The policy never executed. + PreflightFailed { step: String }, + /// Class 3 — a declared prerequisite step failed, so the policy step could + /// not run. Distinct from class 2: the runner was fine; a tool, credential + /// or configuration fact was not there. + PrerequisiteMissing { step: String }, + /// Class 4-shaped — a policy step concluded failure. The policy *was* + /// reached; whether that is a genuine finding must be read from its own + /// report (see the module's evidence-tier note). + PolicyStepFailed { step: String }, + /// Class 5 — the job passed and a policy step recorded success. + Verified { step: String }, + /// No claim, with a reason. + Unclassified { reason: UnclassifiedReason }, +} + +impl ExecutionClass { + /// A stable kebab-case label for reports and JSON. + pub fn label(&self) -> &'static str { + match self { + Self::NotStarted => "not-started", + Self::PreflightFailed { .. } => "preflight-failed", + Self::PrerequisiteMissing { .. } => "prerequisite-missing", + Self::PolicyStepFailed { .. } => "policy-step-failed", + Self::Verified { .. } => "verified", + Self::Unclassified { .. } => "unclassified", + } + } + + /// The step this class is about, when it is about one. + pub fn step(&self) -> Option<&str> { + match self { + Self::NotStarted | Self::Unclassified { .. } => None, + Self::PreflightFailed { step } + | Self::PrerequisiteMissing { step } + | Self::PolicyStepFailed { step } + | Self::Verified { step } => Some(step), + } + } + + /// Where to hand this, and to whom. + pub fn handoff(&self) -> Handoff { + match self { + Self::NotStarted => Handoff::Admission, + Self::PreflightFailed { .. } => Handoff::Provisioning, + Self::PrerequisiteMissing { .. } => Handoff::Prerequisites, + Self::PolicyStepFailed { .. } => Handoff::PolicyReport, + Self::Verified { .. } => Handoff::NoClaim, + Self::Unclassified { .. } => Handoff::NoClaim, + } + } + + /// One line a human can act on. Concrete on purpose: a diagnosis nobody can + /// act on is only marginally better than none. + pub fn explain(&self) -> String { + match self { + Self::NotStarted => "no job exists, so no check report could be created — attribute it with `squabble zero-job` / `squabble chains`, never infer a cause from the conclusion alone".to_string(), + Self::PreflightFailed { step } => + format!("`{step}` failed before any policy step ran: this is provisioning, not a policy finding"), + Self::PrerequisiteMissing { step } => + format!("`{step}` could not establish a declared tool/credential/configuration prerequisite, so the policy step never ran"), + Self::PolicyStepFailed { step } => + format!("`{step}` ran and concluded failure: the policy was reached — read its own report for the finding"), + Self::Verified { step } => + format!("`{step}` ran and succeeded: an executed success, subject to the green-polarity check for vacuity"), + Self::Unclassified { reason } => + format!("no class claimed ({}) — the run still must be explained", reason.label()), + } + } + + /// True only for a class that carries positive evidence of execution. + /// Deliberately **not** a gate verdict: `polarity` must still be consulted + /// before this may be read as a green, and no caller may treat it as one. + pub fn is_evidence_bearing_success(&self) -> bool { + matches!(self, Self::Verified { .. }) + } +} + +/// Classify one job's step evidence. +/// +/// * `job` — the job's realised conclusion ([`CheckRun`]) as GitHub reports it, +/// or `None`/`Some(CheckRun::Missing)` when no job exists at all. +/// * `steps` — the jobs-API step list, in API order. +/// * `signatures` — the host's declared setup/prerequisite step names. +/// +/// For a concluded job with steps, failures take precedence over success: +/// setup failures outrank prerequisite failures, which outrank policy failures, +/// regardless of their order in `steps`. The first failure in the winning +/// category is returned. A passed job with no failures is verified using the +/// last successful policy step; otherwise it is unclassified. +pub fn classify( + job: Option, + steps: &[StepOutcome], + signatures: &PhaseSignatures, +) -> ExecutionClass { + // Class 1 — nothing started. Attribution is `startup`'s job, not ours. + let Some(job) = job.filter(|c| !matches!(c, CheckRun::Missing)) else { + return ExecutionClass::NotStarted; + }; + + if matches!(job, CheckRun::Pending) { + return ExecutionClass::Unclassified { + reason: UnclassifiedReason::NotConcluded, + }; + } + + if steps.is_empty() { + return ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoStepEvidence, + }; + } + + // Classes 2 and 3 — the earliest failing preflight/prerequisite step wins. + // Setup is checked first because it runs first (and so that a name declared + // in both lists cannot be read as a prerequisite). + if let Some(step) = steps + .iter() + .find(|s| s.conclusion == StepConclusion::Failure && signatures.is_setup(&s.name)) + { + return ExecutionClass::PreflightFailed { + step: step.name.clone(), + }; + } + if let Some(step) = steps + .iter() + .find(|s| s.conclusion == StepConclusion::Failure && signatures.is_prerequisite(&s.name)) + { + return ExecutionClass::PrerequisiteMissing { + step: step.name.clone(), + }; + } + + // The policy was reached. A failing policy step is reported as *where*, not + // *why* — see the module's evidence-tier note. + if let Some(step) = steps + .iter() + .find(|s| s.conclusion == StepConclusion::Failure && signatures.is_policy_step(&s.name)) + { + return ExecutionClass::PolicyStepFailed { + step: step.name.clone(), + }; + } + + // A failure recorded only by a trailing cleanup step says nothing about the + // policy. + if let Some(step) = steps + .iter() + .find(|s| s.conclusion == StepConclusion::Failure) + { + return ExecutionClass::Unclassified { + reason: if signatures.is_post(&step.name) { + UnclassifiedReason::FailureOutsidePolicyStep + } else { + UnclassifiedReason::NoFailingStep + }, + }; + } + + // Class 5 — positive evidence only: a *policy* step recorded success and the + // job passed. A green whose policy step was skipped is not verified here; + // `polarity` owns the vacuity question. + if matches!(job, CheckRun::Passed) { + if let Some(step) = steps + .iter() + .rev() + .find(|s| s.conclusion == StepConclusion::Success && signatures.is_policy_step(&s.name)) + { + return ExecutionClass::Verified { + step: step.name.clone(), + }; + } + return ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoSuccessfulStep, + }; + } + + // Job failed/skipped with no failed step recorded (cancelled, timed out). + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoFailingStep, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sig() -> PhaseSignatures { + PhaseSignatures { + setup_steps: vec![ + "Checkout repository".into(), + "Install Rust toolchain".into(), + "Cache cargo registry and build".into(), + ], + prerequisite_steps: vec!["Probe whether fight can inspect the full gate".into()], + } + } + + fn step(name: &str, c: StepConclusion) -> StepOutcome { + StepOutcome::new(name, c) + } + + fn setup_failed_step() -> StepOutcome { + step(SETUP_JOB_STEP, StepConclusion::Failure) + } + + #[test] + fn no_job_is_not_started_and_never_a_class_about_steps() { + // Even with step text in hand, "no job" wins: the estate's first class is + // decided before any step could exist. + let steps = vec![step("Cargo check", StepConclusion::Failure)]; + assert_eq!(classify(None, &steps, &sig()), ExecutionClass::NotStarted); + assert_eq!( + classify(Some(CheckRun::Missing), &steps, &sig()), + ExecutionClass::NotStarted + ); + assert_eq!(ExecutionClass::NotStarted.handoff(), Handoff::Admission); + } + + #[test] + fn a_runner_side_setup_failure_is_preflight_not_a_finding() { + // The regression this module exists for: `Set up job` failing used to be + // an indistinguishable `CheckRun::Failed`, which `fight` attributed to + // the workflow's *kind* (a code fixer, or a security scan). + let steps = vec![ + setup_failed_step(), + step("Cargo check", StepConclusion::Skipped), + ]; + let c = classify(Some(CheckRun::Failed), &steps, &sig()); + assert_eq!( + c, + ExecutionClass::PreflightFailed { + step: SETUP_JOB_STEP.into() + } + ); + assert_eq!(c.handoff(), Handoff::Provisioning); + assert!(!c.explain().contains("finding")); + } + + #[test] + fn a_declared_provisioning_step_is_preflight() { + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Install Rust toolchain", StepConclusion::Failure), + step("Cargo check", StepConclusion::Skipped), + ]; + let c = classify(Some(CheckRun::Failed), &steps, &sig()); + assert_eq!( + c, + ExecutionClass::PreflightFailed { + step: "Install Rust toolchain".into() + } + ); + } + + #[test] + fn an_undeclared_setup_looking_step_is_not_guessed_at() { + // No signature declares it, so it is read as a policy step. The module + // reports where, not why — and the directive is what moves the line. + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Some Private Setup Thing", StepConclusion::Failure), + ]; + assert_eq!( + classify(Some(CheckRun::Failed), &steps, &sig()), + ExecutionClass::PolicyStepFailed { + step: "Some Private Setup Thing".into() + } + ); + } + + #[test] + fn a_failed_prerequisite_is_its_own_class_not_preflight() { + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Checkout repository", StepConclusion::Success), + step( + "Probe whether fight can inspect the full gate", + StepConclusion::Failure, + ), + ]; + let c = classify(Some(CheckRun::Failed), &steps, &sig()); + assert_eq!( + c, + ExecutionClass::PrerequisiteMissing { + step: "Probe whether fight can inspect the full gate".into() + } + ); + assert_eq!(c.handoff(), Handoff::Prerequisites); + assert_ne!(c.handoff(), Handoff::Provisioning); + } + + #[test] + fn setup_outranks_prerequisite_when_both_would_match() { + let both = PhaseSignatures { + setup_steps: vec!["Install Rust toolchain".into()], + prerequisite_steps: vec!["Install Rust toolchain".into()], + }; + let steps = vec![step("Install Rust toolchain", StepConclusion::Failure)]; + assert_eq!( + classify(Some(CheckRun::Failed), &steps, &both), + ExecutionClass::PreflightFailed { + step: "Install Rust toolchain".into() + } + ); + } + + #[test] + fn a_policy_step_failure_names_the_step_and_hands_to_the_policy_owner() { + // The estate's rust reusable records `Cargo check`, `Cargo fmt`, + // `Cargo clippy` after its toolchain install (measured 2026-10-31). + // A `--locked` dependency error and a compile error are BOTH this shape, + // so the class must not claim to know which. + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Checkout repository", StepConclusion::Success), + step("Install Rust toolchain", StepConclusion::Success), + step("Cache cargo registry and build", StepConclusion::Success), + step("Cargo check", StepConclusion::Failure), + step("Cargo fmt", StepConclusion::Skipped), + ]; + let c = classify(Some(CheckRun::Failed), &steps, &sig()); + assert_eq!( + c, + ExecutionClass::PolicyStepFailed { + step: "Cargo check".into() + } + ); + assert_eq!(c.handoff(), Handoff::PolicyReport); + assert!(c.explain().contains("read its own report")); + assert!( + !c.is_evidence_bearing_success(), + "reaching the policy is not a success" + ); + } + + #[test] + fn a_verified_run_names_the_step_that_ran() { + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Checkout repository", StepConclusion::Success), + step("Cargo check", StepConclusion::Success), + step("Post job", StepConclusion::Success), + ]; + assert_eq!( + classify(Some(CheckRun::Passed), &steps, &sig()), + ExecutionClass::Verified { + step: "Cargo check".into() + } + ); + } + + #[test] + fn a_green_with_no_successful_policy_step_is_not_verified() { + // The all-skipped shape: `polarity` calls it vacuous, this module + // refuses to call it executed. Neither may be read as a green. + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Run Hypatia scan", StepConclusion::Skipped), + ]; + let c = classify(Some(CheckRun::Passed), &steps, &sig()); + assert_eq!( + c, + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoSuccessfulStep + } + ); + assert!(!c.is_evidence_bearing_success()); + } + + #[test] + fn a_green_from_a_preflight_step_alone_is_not_verified() { + // Only setup ran and succeeded; nothing about the policy is known. + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Checkout repository", StepConclusion::Success), + ]; + assert_eq!( + classify(Some(CheckRun::Passed), &steps, &sig()), + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoSuccessfulStep + } + ); + } + + #[test] + fn a_cleanup_failure_is_not_a_policy_finding() { + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Cargo check", StepConclusion::Success), + step("Post job", StepConclusion::Failure), + ]; + let c = classify(Some(CheckRun::Failed), &steps, &sig()); + assert_eq!( + c, + ExecutionClass::Unclassified { + reason: UnclassifiedReason::FailureOutsidePolicyStep + } + ); + assert_eq!(c.handoff(), Handoff::NoClaim); + } + + #[test] + fn an_empty_step_list_is_the_honest_non_answer() { + let c = classify(Some(CheckRun::Failed), &[], &sig()); + assert_eq!( + c, + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoStepEvidence + } + ); + // Symbolically identical to a skipped job's evidence: absence, not + // evidence of absence. + assert_eq!( + classify(Some(CheckRun::Skipped), &[], &sig()), + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoStepEvidence + } + ); + } + + #[test] + fn an_unconcluded_job_is_not_classified() { + assert_eq!( + classify(Some(CheckRun::Pending), &[], &sig()), + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NotConcluded + } + ); + } + + #[test] + fn a_failure_with_no_failing_step_is_not_attributed() { + // Cancelled/timeout: the conclusion says failure, no step recorded one. + let steps = vec![ + step(SETUP_JOB_STEP, StepConclusion::Success), + step("Cargo check", StepConclusion::Cancelled), + ]; + assert_eq!( + classify(Some(CheckRun::Failed), &steps, &sig()), + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoFailingStep + } + ); + } + + #[test] + fn matching_is_exact_so_an_abbreviated_name_cannot_match() { + // The `gate_triage.a2ml` failure mode, re-asserted here: an abbreviated + // signature silently matches nothing. It must therefore leave the step + // in the policy lane, not mis-file it as preflight. + let abbreviated = PhaseSignatures { + setup_steps: vec!["Install Rust".into()], + prerequisite_steps: vec![], + }; + let steps = vec![step("Install Rust toolchain", StepConclusion::Failure)]; + assert_eq!( + classify(Some(CheckRun::Failed), &steps, &abbreviated), + ExecutionClass::PolicyStepFailed { + step: "Install Rust toolchain".into() + } + ); + assert!(!abbreviated.is_setup("Install Rust toolchain")); + // Surrounding whitespace is not an abbreviation, and is tolerated. + assert!(abbreviated.is_setup(" Install Rust ")); + } + + #[test] + fn an_empty_signature_set_attributes_no_preflight_class() { + let empty = PhaseSignatures::default(); + assert!(!empty.is_usable()); + let steps = vec![step("Install Rust toolchain", StepConclusion::Failure)]; + // Only GitHub's implicit step is universal; everything else stays policy. + assert!(matches!( + classify(Some(CheckRun::Failed), &steps, &empty), + ExecutionClass::PolicyStepFailed { .. } + )); + assert!(matches!( + classify(Some(CheckRun::Failed), &[setup_failed_step()], &empty), + ExecutionClass::PreflightFailed { .. } + )); + } + + #[test] + fn classes_round_trip_as_json_and_carry_no_gate_state() { + let cases = [ + ExecutionClass::NotStarted, + ExecutionClass::PreflightFailed { + step: "Set up job".into(), + }, + ExecutionClass::PrerequisiteMissing { + step: "Probe".into(), + }, + ExecutionClass::PolicyStepFailed { + step: "Cargo check".into(), + }, + ExecutionClass::Verified { + step: "Cargo test".into(), + }, + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoStepEvidence, + }, + ]; + for c in &cases { + let json = serde_json::to_string(c).expect("serialisable"); + let back: ExecutionClass = serde_json::from_str(&json).expect("round trips"); + assert_eq!(&back, c); + assert!(!json.contains("green"), "{json}"); + } + assert!(serde_json::to_string(&ExecutionClass::PreflightFailed { + step: "Set up job".into() + }) + .unwrap() + .contains("\"class\":\"preflight-failed\"")); + } + + #[test] + fn only_a_verified_class_is_evidence_bearing_and_it_still_needs_polarity() { + let failing = classify( + Some(CheckRun::Failed), + &[step("Cargo check", StepConclusion::Failure)], + &sig(), + ); + assert!(!failing.is_evidence_bearing_success()); + let verified = classify( + Some(CheckRun::Passed), + &[step("Cargo check", StepConclusion::Success)], + &sig(), + ); + assert!(verified.is_evidence_bearing_success()); + // It is still not a gate verdict: the handoff claims nothing, so no + // caller can read it as "this repository is green". + assert_eq!(verified.handoff(), Handoff::NoClaim); + } + + #[test] + fn every_class_names_a_handoff_and_a_reason_a_human_can_act_on() { + for c in [ + ExecutionClass::NotStarted, + ExecutionClass::PreflightFailed { + step: "Set up job".into(), + }, + ExecutionClass::PrerequisiteMissing { step: "P".into() }, + ExecutionClass::PolicyStepFailed { + step: "Cargo test".into(), + }, + ExecutionClass::Verified { + step: "Cargo test".into(), + }, + ExecutionClass::Unclassified { + reason: UnclassifiedReason::NoFailingStep, + }, + ] { + assert!(!c.explain().is_empty()); + assert!(!c.handoff().owner().is_empty()); + assert!(!c.label().is_empty()); + } + } +} diff --git a/crates/squabble-core/src/lib.rs b/crates/squabble-core/src/lib.rs index f150373c..85b104cd 100644 --- a/crates/squabble-core/src/lib.rs +++ b/crates/squabble-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod admission; pub mod board; pub mod chains; pub mod done; +pub mod execution; pub mod gate; pub mod inbox; pub mod moves; diff --git a/crates/squabble-fight/src/execution.rs b/crates/squabble-fight/src/execution.rs new file mode 100644 index 00000000..d94a9065 --- /dev/null +++ b/crates/squabble-fight/src/execution.rs @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +//! Reads the `execution_taxonomy` bot directive — the host half of +//! [`squabble_core::execution`]. +//! +//! [`squabble_core::execution`] is deliberately vocabulary-free: it holds no +//! step names. They live in +//! `.machine_readable/bot_directives/execution_taxonomy.a2ml`, in +//! `[execution-taxonomy]`, and this module is what turns that file into a +//! [`PhaseSignatures`]. +//! +//! Fail-safe, exactly like [`crate::gate_triage`] and [`crate::context`]: an +//! absent, unreadable or unrecognised directive yields an *unusable* signature +//! set, and an unusable set attributes no preflight class. A missing directive +//! therefore means "claim no preflight cause", never "everything is preflight". +//! The classifier still reports GitHub's implicit `Set up job`, which is a +//! GitHub primitive rather than a repo declaration. + +use squabble_core::execution::PhaseSignatures; +use std::path::Path; + +use crate::context::extract_array; + +/// Where the directive lives, relative to a repo checkout. +pub const DIRECTIVE_PATH: &str = ".machine_readable/bot_directives/execution_taxonomy.a2ml"; + +/// The section that carries the step-name signatures. +pub const SECTION: &str = "[execution-taxonomy]"; + +/// Load the phase signatures from a repo checkout. If the directive cannot be +/// read, return empty signatures; the implicit GitHub setup step still matches. +pub fn load_signatures(repo_root: &Path) -> PhaseSignatures { + let raw = std::fs::read_to_string(repo_root.join(DIRECTIVE_PATH)).unwrap_or_default(); + parse_signatures(&raw) +} + +/// Pure half — the unit of test coverage. [`load_signatures`] only supplies the +/// file's text. +/// +/// Scoped to [`SECTION`] on purpose: `extract_array` is line-oriented and would +/// otherwise read a `setup-steps` key written as an example inside any other +/// section. A table or section header closes the scope, so a following section +/// cannot donate its arrays to this one. +pub fn parse_signatures(raw: &str) -> PhaseSignatures { + let section = section_of(raw, SECTION); + PhaseSignatures { + setup_steps: extract_array(section, "setup-steps"), + prerequisite_steps: extract_array(section, "prerequisite-steps"), + } +} + +/// The text after the first occurrence of `header`, up to the next header line. +/// +/// Returns an empty string when the header is absent — an absent section is an +/// absent declaration, and returning the whole file would let an example line +/// elsewhere become policy. +fn section_of<'a>(raw: &'a str, header: &str) -> &'a str { + let Some(i) = raw.find(header) else { + return ""; + }; + let rest = &raw[i + header.len()..]; + let mut end = rest.len(); + let mut at = 0usize; + for line in rest.split_inclusive('\n') { + let code = line.split('#').next().unwrap_or("").trim(); + if code.starts_with('[') { + end = at; + break; + } + at += line.len(); + } + &rest[..end] +} + +#[cfg(test)] +mod tests { + use super::*; + + const DIRECTIVE: &str = r#" +[metadata] +version = "0.1.0" + +[execution-taxonomy] +# setup-steps = ["A commented example that must not be policy"] +setup-steps = [ + "Set up job", + "Checkout repository", + "Install Rust toolchain", +] +prerequisite-steps = ["Probe whether fight can inspect the full gate"] + +[execution-taxonomy.notes] +setup-steps = ["this belongs to a different section and must not be read"] +"#; + + #[test] + fn parses_the_declared_lists_and_ignores_the_gaps() { + let s = parse_signatures(DIRECTIVE); + assert_eq!( + s.setup_steps, + vec![ + "Set up job".to_string(), + "Checkout repository".to_string(), + "Install Rust toolchain".to_string(), + ] + ); + assert_eq!( + s.prerequisite_steps, + vec!["Probe whether fight can inspect the full gate".to_string()] + ); + assert!(s.is_usable()); + } + + #[test] + fn a_subsection_cannot_donate_its_keys() { + // The trailing `[execution-taxonomy.notes]` redefines `setup-steps`. + // Section scoping is what keeps that out. + let s = parse_signatures(DIRECTIVE); + assert!(!s + .setup_steps + .iter() + .any(|x| x.contains("different section"))); + } + + #[test] + fn an_absent_directive_is_unusable_not_permissive() { + let s = parse_signatures(""); + assert!(!s.is_usable()); + assert!(s.setup_steps.is_empty() && s.prerequisite_steps.is_empty()); + // Unusable means "claim no preflight class", which is the fail-closed + // answer — not "everything is preflight". + assert!(!s.is_setup("Install Rust toolchain")); + // GitHub's implicit step is a primitive, not a declaration, so it is + // still recognised. + assert!(s.is_setup("Set up job")); + } + + #[test] + fn a_comment_only_declaration_declares_nothing() { + let s = parse_signatures( + "[execution-taxonomy]\n# setup-steps = [\"Install Rust toolchain\"]\n", + ); + assert!(!s.is_usable()); + assert!(!s.is_setup("Install Rust toolchain")); + } + + #[test] + fn a_missing_repo_root_loads_to_the_unusable_set() { + let s = load_signatures(Path::new("/nonexistent-repo-root")); + assert!(!s.is_usable()); + } + + #[test] + fn this_repos_own_directive_names_only_steps_that_exist_in_this_estate() { + // Ground truth, re-read on every run: the shipped directive must parse, + // or the classifier silently degrades to "policy step" for everything. + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root"); + let s = load_signatures(root); + assert!( + s.is_usable(), + "{} must declare at least one step name", + DIRECTIVE_PATH + ); + // `Set up job` is compiled in, so declaring it adds nothing; the + // directive's job is the repo-specific names. + assert!(s + .setup_steps + .iter() + .chain(s.prerequisite_steps.iter()) + .any(|n| n != "Set up job")); + } +} diff --git a/crates/squabble-fight/src/lib.rs b/crates/squabble-fight/src/lib.rs index 98731e64..30c485f4 100644 --- a/crates/squabble-fight/src/lib.rs +++ b/crates/squabble-fight/src/lib.rs @@ -28,6 +28,7 @@ pub mod apply; pub mod chains; pub mod context; +pub mod execution; pub mod gate_triage; pub mod startup; pub mod workflows; diff --git a/docs/README.adoc b/docs/README.adoc index 419c401a..180a3aee 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -17,5 +17,12 @@ Downstream integration contracts (published revisions, not branches): Core docs: +* `reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc` — squabbler's + role in estate CI diagnosis: the five outcomes (not started · preflight · + prerequisite · executed finding · verified), who diagnoses and who remediates + each, gaps, owner handoffs for inaccessible settings, and proposals kept + separate from implemented capabilities +* `../.machine_readable/bot_directives/execution_taxonomy.a2ml` — the step-name + signatures behind `squabble phase` (host-supplied; no step name is compiled in) * `maintenance/MAINTENANCE-CHECKLIST.md` * `practice/SOFTWARE-DEVELOPMENT-APPROACH.adoc` diff --git a/docs/reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc b/docs/reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc new file mode 100644 index 00000000..a12e4af5 --- /dev/null +++ b/docs/reports/estate-integration/2026-10-31-ci-diagnosis-coverage.adoc @@ -0,0 +1,766 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell += cicd-squabbler in estate CI diagnosis: role, coverage, gaps, handoffs +:toc: left +:toclevels: 3 +:sectnums: + +*Date:* 2026-10-31 +*Status:* investigation + one narrowly scoped implementation (see §10). No +policy was disabled, no issue or PR was closed, and no verdict was redefined. +*Question:* given that estate CI failures split into five outcomes automation +must keep distinct, what is `cicd-squabbler`'s intended role, what can it +actually detect and explain today, and what must be handed to whom? + +[IMPORTANT] +==== +*Evidence tier — read this first.* Claims below are of three kinds, and they are +labelled: + +. *Read from code in this repository* (file + symbol named). Strongest. +. *Read from a sibling repository's canon through the GitHub API on 2026-10-31* + (`gh api repos/...`): `metadatastician/standards`, `hyperpolymath/cicd-suite`, + `hyperpolymath/hypatia`, `hyperpolymath/reposystem`, + `hyperpolymath/gitbot-fleet`, `hyperpolymath/proven-tests-and-benches`. +. *NOT verified here.* This authoring environment has **no Rust toolchain** + (`cargo`/`rustc` absent; `static.rust-lang.org` and GitHub release assets are + both unreachable), so nothing in §10 was **executed** here, and no `squabble` + binary was run against live GitHub. What CI has since established (PR #145, + commit `b2b9f82`): `rust-ci`'s `Cargo check --workspace --all-targets --locked` + **passes**, so the new modules and their test code type-check; `Cargo fmt` + **fails**, which stops the job before `clippy` and before `Cargo test`, so the + 31 new unit tests are type-checked but still **not executed**. The formatting + difference is real and is *not* fixed here — see §10.3. Everything the *existing* code does is read, not + observed. Measurements quoted from earlier estate work are attributed to the + document that recorded them. +==== + +== 1. What squabbler says it is (the canon that binds this investigation) + +From this repository's own `ANCHOR`/`ECOSYSTEM` statements and +`docs/CHARTER.adoc`: + +*IS*:: a standalone per-repo CI/CD **fighter**. It drives a stuck branch +protection gate to a *legitimately satisfied* green by satisfying its required +checks, or improves red→yellow, or fails and emits a structured report. + +*IS-NOT*:: a bypass (never `enforce_admins=off`, never drop a required context, +never admin-merge) · hypatia-dependent · a red→green code-fixer · a merge-commit +lander. + +Two further docs constrain its *rank*, not just its scope, and they are the +reason this report is mostly about **handoffs** rather than features: + +. `.machine_readable/bot_directives/gate_triage.a2ml` declares a precedence + order: `gitbot-fleet/robot-repo-automaton` is **primary** wherever it is + "present AND working", and `cicd-squabbler` is the **fallback**, bound by + `fallback-must-be-standalone = true`. The squabbler exists for repos that do + not adopt the full stack, or for the estate when the primary is down, polluted + or out of paid CI minutes. +. The directive also fixes the invariant that shapes every answer below: an + unavailable gate **classifies as `escalate`, never `self-win`**, and + `may-transition-to-green = false`. + +So the honest role statement is: **squabbler is the per-repo *gate-semantics* +specialist and the estate's fallback classifier. It explains why a required +context is absent or unsatisfiable, and who must act. It does not own runner +provisioning, Actions settings, estate inventory, policy findings, or evidence +provenance — and it must not pretend to.** + +== 2. The five outcomes, and who decides each + +The estate investigation separates these outcomes because collapsing them is how +a red board becomes a lie: + +[cols="1,4",options="header"] +|=== +| # | Outcome + +| 1 | *Not started* — workflow admission or source resolution failed; zero jobs, no +log, no check run. +| 2 | *Preflight* — a job started and died in runner setup, provisioning or lock +resolution, **before any policy step executed**. +| 3 | *Prerequisite* — a tool, credential or configuration fact was missing, so the +policy step could not run. +| 4 | *Executed finding* — the policy/security/test actually ran and reported +something. +| 5 | *Verified success* — the policy ran and passed, with evidence. +|=== + +Nobody in the estate owns all five. The division of labour, measured from the +sibling repositories' own canon: + +[cols="1,2,2,2",options="header"] +|=== +| Outcome | Diagnoses | Remediation of record | Never + +| 1 | `squabble zero-job` (per-run attribution), `squabble chains` (moved +upstreams *before* a run proves it) · hypatia `scripts/ci-health` `B-ALLOWLIST`, +`B-STARTUPFAIL`, `B-LOCKFILE` · standards `allowlist-preflight-reusable.yml`, +`lockfile-drift-detect.yml` +| hypatia `remediate.sh` PUTs the curated superset (`action-superset.txt`), then +reads the setting back; settings drift by standards +`settings-drift-detect.yml`; a human clears billing +| A workflow edit is never the fix for an allow-list refusal; no bot infers a +cause from the conclusion alone + +| 2 | *nobody, from step evidence* — hypatia reads **job annotations** +(`B-LOCKFILE`), and `A-BILLING` is an annotation class too +| owner of the provisioning (toolchain, lock regeneration with the authoritative +tool, runner label, billing) +| A provisioning failure is never reported as a code/security finding + +| 3 | `cicd-squabbler` for the *policy-shaped* subset (Actions posture, SHA +pinning, allow-list); otherwise the owner's own preflight +| owner of settings/secrets; standards `provisioning-check-reusable.yml` +| A missing secret is never reported as a scan finding, and a missing tool is +never reported as a pass + +| 4 | the producing policy (cicd-suite gates, hypatia SARIF, tests) · +`cicd-squabbler` only *routes* it (`EscalateToExpert`) +| hypatia + fleet (`hypatia-mcp`/`fleet-mcp`) where adopted; the repo's own owner +otherwise +| squabbler never fixes code to turn a finding green (`IS-NOT`) + +| 5 | `Gate::evaluate` + `polarity` (vacuity) + `contracts/squabble` +`legitimate_green` · proven-tests-and-benches provenance tiers for *test* claims +| nobody: it is either evidenced or it is not +| A skipped, unavailable, vacuous or unmeasured check is never green +|=== + +== 3. Coverage matrix: what squabbler can detect and explain today + +Verdicts: *covered* (detects **and** explains), *partial* (detects, explains +weakly, or covers one sub-case), *gap* (neither). + +=== 3.1 Outcome 1 — not started (zero jobs) + +*Covered, and better than any sibling on the two attribution classes.* + +* `squabble_core::startup` models `StartupFailureClass::{MovedCoordinate, + AllowListAdmissionRequired, Unattributed}` with **distinct moves** + (`RepointToCanonical` carrying the same pin; `AllowListAdmissionRequired` + naming the minimal `owner/repo@*`). `AllowList::covers` / + `missing_pattern` / `admission_unknown` encode the `selected` semantics, + including `github_owned_allowed` and the `verified_allowed` blind spot, so an + unresolvable creator status degrades to *unknown*, never to *refused*. +* `squabble zero-job` reports per run: repo, workflow, coordinate as written, + canonical target, required pattern, and the sequencing (repoint first, then + admit). Exit `4` on *any* observed zero-job run — including unattributed ones, + "because silence is not a clean result". +* `squabble chains` resolves every upstream through REST with redirects followed + and compares canonical `full_name`s — **names, never pins** — so a + renamed/transferred upstream is named before a run proves it. +* `MissingCause` in `squabble_core::gate` keeps the causes of a missing context + distinct — `NoSuchJob`, `DeadActionPin`, `ActionsPolicyRefusal`, + `MovedUpstreamCoordinate`, `AllowListAdmissionRequired`, `EmptyJobsMap`, + `NotTriggeredOnPullRequest`, and an explicit `Unknown` — each with its own + remedy string, and a test asserting that *no* cause can move a gate to green. + +*What it does not explain:* the two causes whose evidence is not a workflow file +or an allow-list — **billing refusal (`A-BILLING`)** and **lockfile drift +(`B-LOCKFILE`)**. These land in `Unattributed` with the probe failure quoted. +That is the correct fail-closed answer, and it is also where this repository's +own CI sits (`.github/settings.yml`: "GitHub Actions is billing-blocked for +PRIVATE repositories on this account … which is why every workflow here fails +with zero steps"). The report says "unattributed"; it does not say *which sibling +already knows the answer*. That is gap *G-4*. + +=== 3.2 Outcome 2 — preflight (runner setup / provisioning / lock) + +*Gap — the largest one. It was not detectable from the gate model, and until +this change there was no notion of it anywhere in the engine.* + +Why: the engine's only input per requirement is `RequiredCheck { run: CheckRun }` +(`CheckRun::{Missing, Pending, Failed, Skipped, Passed}`). A job that dies in +`Set up job`, or in `Install Rust toolchain`, arrives as `CheckRun::Failed` — +*the same value as a genuine failing test*. `squabble_fight::workflows:: +classify_with_policy` then attributes the red from the **shape of the workflow**: + +* reusable-in-another-repo → `AssignGateOwner{OwnedUpstream}`; +* `CodeBuild` → `EscalateToExpert{HypatiaFleet, DispatchFix}`; +* `Security` → `EscalateToExpert{Security, Scan}`; +* `Hygiene` → `GroundTruthCheckNames`. + +For a preflight failure every one of those is a *misattribution*: nothing has +been said about the code, the scan, or the reusable. The estate has measured +both shapes: + +* `A-BILLING` — "the job was not started because recent account payments have + failed…", which manifests on code-scanning gates as "waiting for results from + CodeQL" because no result is ever uploaded (hypatia `scripts/ci-health`, + class `A-BILLING`). +* `B-LOCKFILE` — an `actions.lock` verification failure, diagnosed by reading + **job annotations** (same source, class `B-LOCKFILE`; standards + `lockfile-drift-detect.yml` reports estate-wide and opens one tracking issue, + never mutating another repository). + +Note the two sources disagree about the *mechanism* and this matters: standards' +`lockfile-drift-detect.yml` header says a drifted lock produces `startup_failure` +with **zero jobs**, while hypatia's ci-health classifies lock failures from +**job annotations** in failed runs. Both can be true (the failure can be +recorded either way depending on where it is refused), which is precisely why +neither conclusion alone is a diagnosis — and hypatia states the rule out loud: +"Do not infer an allow-list defect from the conclusion alone." + +*Implemented in this change:* `squabble_core::execution` (see §10) classifies +step evidence into a distinct `PreflightFailed` class, and +`squabble phase ` prints it with an owner handoff. It is **not yet +wired into `fight`** — that needs a jobs fetch for failed checks (*P-2*). + +=== 3.3 Outcome 3 — missing tools, credentials or configuration + +*Partial.* Split three ways today: + +* *Actions posture* — well covered and first-class: + `ReconcileActionsPolicy`, `SetActionsAllowedAll`, `PinWorkflowActions`, + `AllowListAdmissionRequired`, plus a **latent** scan (`structural_scan` in + `squabble_fight::lib`) that surfaces an allow-list gap or a tag-pinned `uses:` + *before* a run proves it. +* *Tools* — `polarity` deliberately refuses to model them: `Recommendation` + omits the directive's case 4 (`missing-local-tooling`) because it "is not + observable from the jobs API". Also `VacuitySignature::is_usable` requires both + halves, so a skipped scan step with no stub-writing step is read as a + legitimately optional step rather than vacuity. +* *Credentials/configuration* — **not modelled at all** before this change, and + still only partly: `squabble_core::execution::PrerequisiteMissing` fires only + for a **host-declared** prerequisite step. The shipped directive declares one, + because only one was measured (see `.machine_readable/bot_directives/ + execution_taxonomy.a2ml`). Everything else stays in the policy lane, which is + the honest default. + +The estate's own example of the shape: standards' `cicd-squabbler-reusable.yml` +probes whether a supplied credential can read the whole gate before asking +`squabble fight` to classify anything, and reports **NOT RUN** (no credential) or +**NOT EVALUATED** (credential present, gate only partly visible) rather than a +verdict. + +=== 3.4 Outcome 4 — genuine executed findings + +*Covered as a handoff, by construction.* `Move::EscalateToExpert` and +`Move::AssignGateOwner` are non-satisfying: `Report` buckets each unsatisfied +check as self-win / escalation / owner assignment, and neither of the latter two +constructs a `CheckRun` or drops a context. `--summon` (feature `boj`) puts +expert verdicts on the report and **fails closed** when an expert is +unreachable. + +*What it does not do — deliberately:* decide whether a finding is real. The +report routes. `Move::FlagNonFunctionalGate` recognises the unwinnable case +(mined from `hypatia#566`: a required check whose own script fails +unconditionally) so the squabbler stops re-diagnosing it every run. + +=== 3.5 Outcome 5 — verified success + +*Covered, with the distinction the estate keeps asking for.* Three layers, and +they are not interchangeable: + +. `Gate::evaluate` → `Green` iff the requirement set is **non-empty** and every + requirement `is_satisfied()`. `is_satisfied()` accepts `Passed` **or** + `Skipped`, mirroring GitHub's ruleset. An empty requirement set is `Blocked` — + the one-line proof that dropping contexts is not a path to green. +. `Gate::evidence_free()` lists the `Skipped` requirements that satisfied the + gate while proving nothing, and `Contract` §5 makes the same distinction + upstream: `legitimate_green` requires *every* required check to have run and + passed, and there are negative fixtures for `skipped` + (`gate/required-skipped.bound.json`, `gate/passed-and-skipped.bound.json`). + So a `Green` gate is explicitly **not** a `legitimate_green`. +. `squabble_core::polarity` interrogates a *green* check for vacuity + (`StubbedAfterSkippedScan`, `AllStepsSkipped`) and routes a finding to the + owner-facing `GateTriage` group — the only escalation group that names no + estate service, because the obligation is the owner's. + +The new `squabble_core::execution::Verified` sits at layer 2.5: a job passed +**and** a policy step recorded success. It is deliberately *not* a gate verdict +(`handoff() == NoClaim`) and must be read with `polarity`. + +=== 3.6 Summary + +[cols="3,1,3",options="header"] +|=== +| Outcome | Verdict | Where + +| 1 not started | covered (2 of 4 causes) | `squabble-core::startup`, `squabble zero-job`, `squabble chains` +| 2 preflight | **gap → new classifier, unwired** | `squabble-core::execution` (§10) +| 3 prerequisites | partial | Actions-policy moves; `PrerequisiteMissing` with a deliberately thin signature +| 4 executed findings | covered as routing | `Move::EscalateToExpert`, `Report` +| 5 verified success | covered, layered | `Gate::evaluate`, `evidence_free`, `polarity`, contract §5 +|=== + +== 4. Does it preserve required contexts? + +Yes, and this is the invariant everything else defers to: + +* No `Move` variant removes or renames a requirement. `ReconcileRequiredContext` + changes the *required* name to match a real emitted name — a ruleset edit, not + a workflow weakening — and is propose-only. +* `Gate::evaluate` cannot reach `Green` from an empty requirement set + (`empty_requirement_set_is_never_green`), and `a_diagnosed_missing_check_still + _cannot_reach_green` iterates every `MissingCause` asserting none reaches + `Green`. +* The new classifier has no path to `Green`: it builds no `CheckRun`, calls no + `evaluate`, and proposes no `Move`. +* `zero-job`, `chains` and `phase` are all read-only, and stand outside + `squabble-contract/1.0.0` (whose surface stays at four subcommands). Nothing + here adds meaning to the pinned revision. + +One honest asymmetry to keep visible: **the gate model and the contract disagree +by design.** GitHub satisfies a requirement with `SKIPPED`; `legitimate_green` +does not. A consumer that reads only `squabble fetch`'s `Green` can therefore +believe more than the contract certifies. The mitigation is `evidence_free()` +being populated and printed — not a redefinition of either verdict. + +== 5. Source and publication dependencies + +`chains` models **source** dependencies: `EdgeKind::{ReusableWorkflow, Action}`, +pins, canonical names, cycles and dead upstreams. It does not model +**publication** dependencies, and those are what actually broke this repository +on 2026-10-28 (`docs/reports/maintenance/2026-10-28-zero-job-allowlist.adoc`): + +[cols="2,3,1",options="header"] +|=== +| Dependency | Evidence it is load-bearing | In `chains`? + +| owner/repo *move* of a reusable (`hyperpolymath/standards` → +`metadatastician/standards`) | 7 workflows died as zero-job `startup_failure`; the +discriminator was the *callee's owner*, nothing else in the caller changed | +partly — canonical resolution catches the move, but the *allow-list precedence* +it triggers is not an edge + +| the curated allow-list superset, published as `action-superset.txt` in hypatia +`scripts/ci-health/` | its README: the superset was updated for the 2026-10-25 +move, "this repository's own allow-list evidently was not" | **no** — a +cross-repo published artifact every repo's settings derive from + +| the squabble release binary + build attestation, consumed by +`metadatastician/standards` as `.github/actions/squabble@1ab1507a…` | that +reusable refuses any binary whose sha256/attestation do not match its pinned +release | **no** — a release is not a `uses:` edge from a *consumer repo* that +`chains` scans + +| `contracts/squabble` and `contracts/proven-tests` (revision + `SHASUMS`) | +`contracts/bin/verify-pin.sh` exists precisely because consumers pin by digest | +**no** + +| the reusable's *content* at a pin | a SHA pin is immutable; a tag pin is not — +`chains` warns on tag/branch pins for this reason | yes +|=== + +*Gap G-5:* a consumer's install of the squabble binary can fail (attestation +mismatch, missing release asset) as a **class-2 preflight failure inside the +consumer's job**, and no squabbler surface describes that dependency. The +composite action's own self-test (`tests/squabble_action_test.sh`, 27 cases) is +the only place it is checked. + +== 6. Owner handoffs for inaccessible settings + +The measured situation: the built-in `GITHUB_TOKEN` cannot read the settings that +decide outcome 1 and 3. This repository's own 2026-10-28 report records +`403 Resource not accessible by integration` on every `actions/permissions*` +endpoint, and standards' reusable records the same for classic branch protection +("the classic-protection endpoint is 403 to the built-in Actions token even +though all 24 ruleset contexts are visible"). The behaviour that follows is +correct and worth stating plainly: **`fight` refuses to reason about a partially +visible gate** (exit 2 with "classic branch protection on this branch is not +visible to this token", `squabble-cli/src/fetch.rs`), and the reusable converts +that into NOT RUN / NOT EVALUATED rather than an all-clear. + +What is *not* precise today is the handoff itself. The squabbler names the +minimal pattern and says "no workflow edit can make the run start", but does not +name **the principal and the surface**: + +[cols="2,2,2,3",options="header"] +|=== +| Setting | Read/write surface | Principal who must act | What squabbler says today + +| Actions allow-list (`allowed_actions`, `patterns_allowed`) | +`GET/PUT repos/{o}/{r}/actions/permissions[/selected-actions]` — `administration: +read` / `administration: write` | *repo admin* for the repo; **org admin** when an +org policy sets the ceiling | the exact `owner/repo@*` pattern + "settings are the +owner's realm" (precise on the *what*, silent on the *who*) + +| SHA-pinning requirement | same endpoints | repo admin (an org policy can +re-impose it) | "keep `sha_pinning_required=true`" + `PinWorkflowActions` + +| Branch ruleset / required contexts | `GET/PUT repos/{o}/{r}/rulesets/...`, +`.../rules/branches/{b}` — `administration: read` (write to change) | repo admin | +`ReconcileRequiredContext` as a propose-only move + +| Classic branch protection | `.../branches/{b}/protection` — 403 to +`GITHUB_TOKEN`, 404 when absent | repo admin | distinguishes 403 from 404 in the +error and refuses to classify on 403 + +| Billing / spending limit | *no API this tool holds* | **account owner** only | +nothing — it degrades to `Unattributed` (correct, but unhelpful: see *G-4*) + +| Secrets existence | `GET .../actions/secrets` (names only) | repo admin | +not modelled at all (*G-2*) + +| Runner label / group availability | no *readable* surface for the queue reason +| repo or org admin | not modelled (*G-2*) +|=== + +*Gap G-3:* the report names a fix but not a principal. The estate currently +compensates in workflow glue (standards' probe + NOT EVALUATED). That is the +right place for the *permission* decision, but the *report* should carry the +principal, so a human reading a fight report knows whether to page an org admin, +a repo admin or the billing owner. `squabble_core::execution::Handoff` now +carries an owner phrase per class as a first step (§10); `fight`'s report does +not yet. + +== 7. Coordination: no competing rewrites + +The estate already has overlapping observers. The rules below are *derived from +what each repository declares*, not invented here. + +[cols="2,3,3",options="header"] +|=== +| Sibling | What it owns | The rule for squabbler + +| `gitbot-fleet` / `robot-repo-automaton` | present-and-working primary for all +five cases, with its own override rules under `.machine_readable/bot_directives/ +rr_automaton/` | yields. Squabbler is the fallback, and must keep working with +the fleet absent (`fallback-must-be-standalone`) + +| `hypatia` (`scripts/ci-health/`) | class detection + **auto-remediation** of +`B-ALLOWLIST` (PUT the curated superset, read back, verify), `D-BURN` (opens +scoped PRs), `A-BILLING`/`B-LOCKFILE`/`B-STARTUPFAIL` (report only); +`baton-bridge.sh` runs checks on owned compute when GitHub CI is blocked | never +PUTs a setting; never opens the D-BURN PR; **names the minimal pattern for one +run** and must not propose a divergent superset. The superset of record is +`hypatia/scripts/ci-health/action-superset.txt` + +| `standards` (reusable gates) | the *wiring*: `allowlist-preflight-reusable.yml` +(tree + credentialed live policy), `actions-lock-gate.yml`, +`lock-sync-gate.yml`, `lockfile-drift-detect.yml`, `required-contexts.yml` (+ +`.machine_readable/required-contexts-allow.txt` as a **declaration**, not a +silencer), `settings-drift-detect.yml`, `check-suite-monitor.yml`, and the +`cicd-squabbler-reusable.yml` consumer contract | squabbler supplies diagnosis to +these; it does not re-implement a preflight, does not edit workflows from CI +(`--apply` is refused by the consumer contract), and does not add an allow-list +"silencer" of its own + +| `cicd-suite` (30 composite audit actions) | the *policy* checks (required-files, +SPDX, hygiene, manifests, language gates) with an explicit tiering: enforcing / +advisory / **tool-dependent (`not_run`, exit 2 — "NO CHECK WAS PERFORMED")** / +unimplemented | consumes their `not_run` distinction as evidence for outcome 3; +never re-runs their logic; never reports their `not_run` as a finding *or* a pass + +| `reposystem` | estate inventory / coordination layer (`repos.toml` +regenerated from `.gitmodules`), megasweep control plane | squabbler never +invents repo identity; the API owner is the identity (`drive off the API owner, +never local dir names` is already a hypatia lesson), and `chains` takes +`path=owner/repo` + +| `proven-tests-and-benches` | evidence provenance for *test* claims +(`Actually-Proven` / `Provisionally-Proven` / `Unproven`; coverage derived only +from tests that ran and passed) | squabbler does not assert test evidence; its +`contracts/proven-tests` contract is the submission format, and a `Verified` +phase class is **not** a provenance tier +|=== + +Two failure modes this report explicitly forbids: + +* **Two bots rewriting one workflow.** Only a human (or a separately reviewed + automation with its own reviewed mandate) edits workflows. Squabbler's + `--apply` enacts one local edit (strip a `on.*.paths` filter that strands a + required check), never commits, never pushes, and is **refused** by the + consumer contract in CI. +* **Two bots "fixing" the same setting with different values.** The allow-list + has one source of record (hypatia's superset). A narrow addition named by + squabbler's report is a *request*, not a competing write. + +== 8. Concrete gaps + +[cols="1,4,1,2",options="header"] +|=== +| ID | Gap | Severity | Evidence + +| G-1 | A job that died in preflight is attributed by *workflow shape* +(`DispatchFix` / `OwnedUpstream` / security scan) — a misattribution that invites +a real code fix for a non-code failure | high (wrong work dispatched) | §3.2; +`classify_with_policy` has no failure-phase input +| G-2 | Credentials and tool availability are not modelled; `polarity` refuses +tool questions outright, correctly, but nothing else answers them | medium | +§3.3; `Recommended` enum omits cases 2 and 4 with reasons +| G-3 | Handoffs name a fix and a pattern but not the **principal** (repo admin +vs org admin vs billing owner) | medium | §6 +| G-4 | An unattributable zero-job run is reported as `Unattributed` with no +cross-reference to the sibling that already knows the class (`A-BILLING`, +`B-LOCKFILE`) | medium | §3.1; this repo's own billing-blocked CI +| G-5 | Publication dependencies (release binary + attestation, contracts by +revision + `SHASUMS`, the allow-list superset) are not in `chains`; an install +failure in a consumer is invisible as a dependency | medium | §5 +| G-6 | The gate model accepts `Skipped` (GitHub's rule) while +`legitimate_green` does not; a consumer reading `fetch` alone can believe more +than the contract certifies | medium (documentation-level, by design) | §4; +contract §5 fixtures +| G-7 | `fight` cannot fetch job step evidence for red checks, so the new phase +classifier is reachable only offline | low (nothing regressed) | §10; `fetch.rs` +builds `greens_from_rollup` only, using `job_id_from_details_url` +| G-8 | The engine has no equivalent of standards' `required-contexts-allow.txt` +**declaration** pattern, and must not acquire one silently: a "known +unsatisfiable" list is exactly where a silent green would hide | note, not a gap +to close before G-1 | standards `required-contexts.yml` header +|=== + +== 9. What was *not* changed + +* No required context was dropped, renamed away, or reconciled. +* No policy, allow-list, ruleset or pinning posture was disabled or loosened. +* No issue or PR was closed, and nothing was re-run to make a board look better. +* No new exit code means "green"; `squabble phase` has only `0` and `2`. +* `squabble-contract/1.0.0` is untouched: its four subcommands and the + `legitimate_green` decision procedure are unchanged, and `phase` — like + `zero-job` and `chains` — is outside the contract surface. + +== 10. Implemented in this change + +=== 10.1 `squabble_core::execution` — the execution-phase classifier + +New pure module, no new dependency, no `Move` variant, no gate change. + +[cols="2,3",options="header"] +|=== +| Class | Meaning + +| `NotStarted` | no job exists (outcome 1) — attribution deferred to `startup` +| `PreflightFailed{step}` | a declared setup/provisioning step (or GitHub's +implicit `Set up job`) concluded failure: **provisioning, not a finding** +| `PrerequisiteMissing{step}` | a declared tool/credential/config step failed, so +the policy step could not run +| `PolicyStepFailed{step}` | a policy step ran and failed — *where*, not *why* +| `Verified{step}` | the job passed **and** a policy step recorded success +| `Unclassified{reason}` | fail-closed non-answer, with one of five reasons +|=== + +Design notes, all of which are tested: + +* **Signatures are host-supplied** (`PhaseSignatures`), never compiled in — the + same standalone discipline as `polarity::VacuitySignature`. Matching is + **exact** after trimming, because this repository has already paid once for + abbreviation drift (`gate_triage.a2ml` 0.2.0 → 0.3.0). +* **Evidence tier stated**: step names and conclusions only. The module docs say + outright that a dependency-resolution error inside `Cargo check --locked` is + indistinguishable from a compile failure here, so `PolicyStepFailed` must not + be read as "class 4 confirmed". Job annotations are the stronger source, and + they belong to the sibling that owns them. +* **Ordering**: preflight is checked before prerequisite, and both before + policy, so nothing later can mask an earlier stop. A failure recorded only by + `Post job`/`Complete job` is *not* a policy finding. +* **No green**: no `CheckRun` is constructed, `Verified` hands off to `NoClaim`, + and the tests assert every class serialises without a gate state. + +=== 10.2 Host wiring + +* `.machine_readable/bot_directives/execution_taxonomy.a2ml` — the step + signatures, each with the workflow it was read from on 2026-10-31, and an + explicit note that `cargo check --locked` is **not** a setup step. +* `squabble_fight::execution::{load_signatures, parse_signatures}` — fail-safe + reader scoped to `[execution-taxonomy]` so no other section can donate keys; + an absent directive yields an unusable set ("claim no preflight class"). +* `squabble phase [--repo ] [--json]` — offline classifier over + a jobs-API payload, printing class, step, handoff label, owner phrase and an + explanation, with a permanent "NOT a gate verdict" line. + +=== 10.3 Tests (stated, not executed here) + +|=== +| Suite | Cases + +| `squabble_core::execution` | 19: `Set up job` failure → preflight (the +regression); declared provisioning step; undeclared step stays policy; failed +prerequisite ≠ preflight; setup outranks prerequisite; policy-step failure names +the step and hands to the policy owner; verified names the step; a green with no +successful policy step is **not** verified; a green from setup alone is not +verified; cleanup failure is not a finding; empty step list is a non-answer; +unconcluded job; no failing step; exact-match/abbreviation guard; empty signature +set; JSON round-trip with no gate state; only `Verified` is evidence-bearing; +every class names a handoff and an actionable reason +| `squabble_fight::execution` | 6: lists parse; a subsection cannot donate keys; +absent directive is unusable not permissive; comment-only declaration declares +nothing; missing root; **this repo's own directive re-read on every run** +| `squabble phase` | 6: arg parsing; bad usage; failed payload parses steps (and +`null` conclusion is `Other`, never a skip); null/empty payload is the no-job +class; payload without steps is absence of evidence; non-JSON is a tool failure +|=== + +*Honesty:* these were written without a Rust toolchain in the authoring +environment. They type-check on CI (`Cargo check --all-targets`, green on PR +#145) but have **not been executed**: `Cargo fmt` fails first and the `Cargo +test` job never starts. `just test` (or `just local-verify`) must be run on a host +with a toolchain before this change is claimed as verified; if any assertion is +wrong, the assertion is the thing to change, not the class. + +The `fmt` failure is worth recording as its own lesson, and the first version of +this paragraph had it **wrong**. It said the only reachable formatter disagreed +with this repository's rustfmt on 7 of 33 files, and that those 7 showed the +opposite choice in the very classes this change uses. The measurement was right; +the conclusion was backwards. Those 7 files are not evidence of what this +repository's rustfmt wants — they are *pre-existing violations*. `main` is not +fmt-clean, and #144 (and #143, which carries the same reformatting) exists to +repair exactly them. + +Re-measured against real rustfmt output instead of against `main`: the wasm +formatter reproduces the eight files reformatted by #144 (real `rustup run stable +cargo fmt`) **byte-for-byte**, bar one hunk in `squabble-core/src/chains.rs` that +is not a formatting change at all (`.map(RepoId::new)` rewritten to a closure, +which rustfmt never does). It is a valid oracle, and it should have been checked +against a formatted tree from the start. + +With that oracle the position is measured, not guessed: + +[cols="1,4"] +|=== +|files |state + +|**0** |files added or modified by this branch still differ from rustfmt output +|**7** |workspace members inherited unformatted from `main` +(`squabble-cli/{chains,fetch,zero_job}.rs`, `squabble-core/{moves,startup}.rs`, +`squabble-fight/startup.rs`, `squabble-forge/rest.rs`) — all repaired by #144 +|**3** |further unformatted files outside the workspace, which `cargo fmt --all` +does not see: two `contracts/proven-tests/downstream-rust-fixture` files and +`verification/squabble-creusot/src/model.rs`, which root `Cargo.toml` `exclude`s +|=== + +Three layouts were therefore **wrongly** changed earlier (`b2b9f82`), because +they were adjudicated against `main`'s unformatted code: two `assert_eq!`s that +rustfmt joins, and one `assert!` it keeps inline. They are in the form rustfmt +actually produces. + +So the merge path is not "one `cargo fmt --all` by the owner". It is: **#144 (or +#143) merges first**, this branch rebases, `fmt` goes green on its own, and only +then do `clippy` and the 31 unit tests execute for the first time. + +=== 10.4 Executable check — run, not asserted + +`tests/aspect_tests.sh` gained an aspect that needs no toolchain and does run +here. It asserts, on the module's **non-test, non-comment** source, that: + +. it cannot name or reach a gate state (`GateState`, `evaluate()`, `gate::Gate`); +. it references no `Move` (a classifier proposes nothing); +. no step name from `execution_taxonomy.a2ml` is compiled into it (the + directive is the only vocabulary — GitHub's implicit `Set up job`, + `Post job`, `Complete job` excepted as primitives); +. it spawns no process and performs no IO. + +[source,text] +---- +$ bash tests/aspect_tests.sh # 2026-10-31 +Aspect 5: diagnosis has no path to green + PASS: execution.rs cannot reach or name a gate state + PASS: execution.rs proposes no move + PASS: no directive step name is compiled into execution.rs + PASS: execution.rs is pure (no process, no IO) +Results: PASS=6 FAIL=1 WARN=0 +---- + +The one `FAIL` is **pre-existing and unrelated**: Aspect 2 greps +`verification/` for forbidden proof markers and trips on the *documentation* that +names them (`verification/proofs/README.adoc`, `coq/TypeSafety.v`). It fails +identically without this change and was deliberately not "fixed" here — silencing +a documentation-lint by editing a proof file is exactly the class of change this +report exists to prevent. + +== 11. Proposals (not implemented) + +[cols="1,4,2,3",options="header"] +|=== +| ID | Proposal | Owner | Acceptance + +| P-1 | **Route preflight failures away from fixers in `fight`.** Give +`classify_with_policy` an optional `ExecutionClass` per red; when `PreflightFailed` +or `PrerequisiteMissing`, emit `AssignGateOwner{OwnedUpstream → provisioning +owner}` or a new non-satisfying move instead of `EscalateToExpert{DispatchFix}`. +Nothing about the gate changes. | this repo | a test where a `Set up job` failure +in a `CodeBuild` workflow produces **no** `DispatchFix` escalation; and the +report names the failing step +| P-2 | **Fetch step evidence for reds.** `fetch.rs` already parses job ids out +of check `details_url` (`job_id_from_details_url`) but keeps only greens +(`greens_from_rollup`). Keep failing checks' job ids too, fetch their steps, and +run `execution` in `plan_with_policy`. Credential-gated like the rest of the +Administration read path. | this repo + standards (credential) | `fight` prints a +phase per red; with no credential it prints `phase: unknown (no step evidence)` +and proposes nothing new +| P-3 | **Name the principal in every settings handoff.** Extend the report's +owner phrase to carry the surface + scope (*repo admin / org admin / billing +owner*), reusing §6's table as the single source. | standards (canon) + this repo +| a fight report on an allow-list refusal names `repos/{o}/{r}/actions/permissions` +and `administration: write` +| P-4 | **Cross-reference sibling detectors on `Unattributed`.** When +`zero-job` cannot attribute a run, and the caller supplies no better evidence, +name the classes it cannot resolve (`A-BILLING`, `B-LOCKFILE`) and the command +that owns each, instead of a bare *unattributed*. Not an attribution claim: the +wording must stay "unresolved; these detectors cover it". | this repo + hypatia +| a billing-blocked repo's report says "not attributed here; hypatia +`scripts/ci-health` class `A-BILLING` covers it" and still exits `4` +| P-5 | **Publication edges in `chains`.** Add `EdgeKind::PublishedRelease` +(release asset + attestation) and `EdgeKind::ContractRevision` (digest pin), so +a consumer's install dependency on the squabble binary and on `contracts/*` is +visible. Blocking only for a *dead* publication; a digest mismatch stays a +warning routed to the consumer's preflight. | this repo | `chains` on +`metadatastician/standards` shows the `.github/actions/squabble@…` edge and its +attestation, and a deleted release blocks +| P-6 | **Declare, do not silence.** If a "known unsatisfiable context" list ever +lands here, model it as standards' `required-contexts-allow.txt` does: a *subset* +check that fails on anything new, with the declaration visible in every report. +Deliberately not implemented now (G-8). | this repo + standards | the list cannot +suppress a new finding, and `fight` prints the declaration it relied on +|=== + +== 12. How to verify this report + +The whole battery runs as one command, on a machine with a toolchain: + +[source,sh] +---- +scripts/local-verify.sh # or: just local-verify +scripts/local-verify.sh --quick # stable only, no Creusot +scripts/local-verify.sh --creusot-prove # also discharge the Creusot obligations +---- + +It writes one `SUMMARY.txt` (per-step status, extracted errors, worktree diff) and +nothing into the repository. Read §10.3/§10.4 with it: a `VOID` step is not a pass. + +The harness's own status vocabulary is itself tested, with planted controls in +both directions (`tests/local_verify_test.sh`, 31 checks, no Rust or network +needed): a missing toolchain and a missing `jq` report `VOID` and exit 2; a +planted compile error exits 1 *and* has its diagnostic and `file:line` +extracted; `--only` leaves every other step `SKIP`; the worktree is unchanged +afterwards. + +[source,sh] +---- +# 1. The code claims (needs a Rust toolchain; absent in the authoring env) +just test +cargo test -p squabble-core execution +cargo test -p squabble-fight execution + +# 2. The new offline entry point, on a real failed job +gh api repos/hyperpolymath/cicd-squabbler/actions/jobs/ > /tmp/job.json +squabble phase /tmp/job.json --repo . # class + handoff + owner +squabble phase /tmp/job.json --repo . --json # machine-readable class + +# 3. The sibling claims this report makes (read-only) +gh api repos/hyperpolymath/hypatia/contents/scripts/ci-health/README.adoc --jq '.content' | base64 -d +gh api repos/metadatastician/standards/contents/.github/workflows/required-contexts.yml --jq '.content' | base64 -d +gh api repos/metadatastician/standards/contents/.github/workflows/lockfile-drift-detect.yml --jq '.content' | base64 -d +gh api repos/metadatastician/standards/contents/.github/workflows/cicd-squabbler-reusable.yml --jq '.content' | base64 -d + +# 4. This repository's own blocked state (outcome 1 + 2, measured earlier) +sed -n '/A required context must name/,/once Actions can run/p' .github/settings.yml +---- + +== 13. One-paragraph summary for the owner + +`squabble` is already the estate's most precise instrument for *why a required +context does not exist* (moved coordinates, allow-list admission, dead pins, +non-emitted names) and for *what must never be called green* (non-empty +requirement set, skipped ≠ passed, vacuity). It had no concept of *where a job +stopped*, so a runner or lock failure was attributed to the workflow's shape — +the one misattribution that dispatches real work for a non-code failure. That +gap now has a pure, fail-closed classifier and an offline command, with signatures +in a directive rather than in the engine, and with handoffs that name an owner +class. It is not yet wired into `fight` (P-1, P-2), it does not name the +principal behind an inaccessible setting (P-3), and it still cannot see billing +or lockfile classes that hypatia already owns (P-4). None of this makes anything +green, and none of it should be read as a green. diff --git a/scripts/local-verify.sh b/scripts/local-verify.sh new file mode 100755 index 00000000..d5205c0e --- /dev/null +++ b/scripts/local-verify.sh @@ -0,0 +1,461 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# local-verify.sh — run every gate this repository's CI would run, locally, and +# leave ONE bundle of evidence to hand back. +# +# WHY THIS EXISTS +# --------------- +# `squabble-core::execution`, `squabble-fight::execution` and `squabble-cli::phase` +# were written in an environment with no Rust toolchain and no reachable +# rustup mirror (static.rust-lang.org is refused there). Their 31 unit tests have +# therefore never been compiled. This script is the smallest honest way to close +# that gap on a machine that *does* have a toolchain: it installs nothing it +# cannot verify (it delegates to scripts/install-toolchain.sh, which verifies +# rustup-init's published SHA-256 and refuses pipes-to-shell), runs the same +# commands CI runs, and writes the raw output where it can be pasted back. +# +# WHAT IT NEVER DOES +# ------------------ +# * no commit, no push, no PR, no issue, no re-run +# * no repository settings, allow-lists, rulesets or pins touched +# * no test weakened, skipped or marked pass to make the summary quieter +# +# FAIL-CLOSED STATUS VOCABULARY (the estate's, not a new one) +# ---------------------------------------------------------- +# PASS the command ran and exited 0 +# FAIL the command ran and exited non-zero — real evidence of a defect +# VOID the command could not run (missing tool/toolchain) — NO CHECK WAS +# PERFORMED. Never printed as a pass. +# SKIP deliberately not requested (e.g. --quick) — also never a pass +# KNOWN a failure this script has POSITIVE evidence predates the branch (the +# same command fails on the branch base for a reason unrelated to it). +# Printed in full, never counted as PASS, not counted as a regression; +# `--strict` makes it exit non-zero too. +# +# Exit codes: 0 every requested step that could run passed · 1 at least one FAIL +# · 2 no FAIL but at least one VOID (nothing was verified) · 3 bad usage. A FAIL +# outranks a VOID — a defect is the more actionable signal — and the VOID list is +# printed either way. +# +# Usage: +# scripts/local-verify.sh # install what is missing, run everything stable +# scripts/local-verify.sh --no-install # use the toolchain already present +# scripts/local-verify.sh --quick # stable only: skip Creusot, contracts yes +# scripts/local-verify.sh --creusot-prove # ALSO discharge the Creusot obligations (slow, network) +# scripts/local-verify.sh --check # report the toolchain and the plan; run nothing +# scripts/local-verify.sh --out DIR # where to write the bundle (default: $TMPDIR/…) +# scripts/local-verify.sh --strict # a KNOWN pre-existing failure fails the run +# scripts/local-verify.sh --only 02-check # run just these steps (comma-separated ids); +# # everything else is recorded SKIP, never PASS +# +# Reading the result: open /SUMMARY.txt. It carries the per-step status, the +# exact errors extracted from each log, and the run's git/rubist/cargo identity. + +set -uo pipefail + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" || exit 3 + +install=true +quick=false +creusot_prove=false +check_only=false +strict=false +only_list= +out_dir="" + +usage() { sed -n '3,54p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } + +while [ "$#" -gt 0 ]; do + case "$1" in + --no-install) install=false ;; + --quick) quick=true ;; + --creusot-prove) creusot_prove=true ;; + --check) check_only=true ;; + --strict) strict=true ;; + --only) shift; only_list="${1:-}" ;; + --out) shift; out_dir="${1:-}" ;; + -h|--help) usage; exit 0 ;; + *) echo "local-verify.sh: unknown argument '$1'" >&2; usage >&2; exit 3 ;; + esac + shift +done + +have() { command -v "$1" >/dev/null 2>&1; } + +# rustup installs to ~/.cargo/bin and does not edit PATH for us +# (install-toolchain.sh passes --no-modify-path on purpose). +if ! have cargo && [ -f "${CARGO_HOME:-$HOME/.cargo}/env" ]; then + # shellcheck disable=SC1091 + . "${CARGO_HOME:-$HOME/.cargo}/env" 2>/dev/null || true +fi + +stamp="$(date -u +%Y%m%dT%H%M%SZ)" +if [ -z "$out_dir" ]; then + base="${TMPDIR:-/tmp}"; base="${base%/}" + out_dir="$base/squabble-verify-$stamp" +fi +mkdir -p "$out_dir" || { echo "cannot create $out_dir" >&2; exit 3; } + +say() { printf '%s\n' "$*"; } + +env_log="$out_dir/00-env.txt" +{ + echo "== identity ==" + echo "generated: $stamp" + echo "repo: $REPO_ROOT" + echo "branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '(detached)')" + echo "head: $(git rev-parse HEAD 2>/dev/null || echo '(no git)')" + echo "upstream: $(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || echo '(none)')" + echo + echo "== platform ==" + echo "os: $(uname -srm)" + echo + echo "== toolchain ==" + for t in rustup rustc cargo rustfmt cargo-clippy gh jq python3 just; do + if have "$t"; then + printf '%-15s %s\n' "$t:" "$("$t" --version 2>&1 | head -1)" + else + printf '%-15s %s\n' "$t:" "MISSING" + fi + done + echo "toolchains:" + rustup toolchain list 2>/dev/null | sed 's/^/ /' || echo " (rustup absent)" + echo + echo "== pinned creusot ==" + grep -E '^(channel|components)' verification/squabble-creusot/rust-toolchain.toml 2>/dev/null || true + echo + echo "== worktree state BEFORE (must stay unchanged) ==" + git status --porcelain 2>/dev/null || echo "(no git)" +} > "$env_log" 2>&1 +say "bundle: $out_dir" +say "" + +if [ "$check_only" = true ]; then + say "toolchain + plan only (--check); nothing was run." + sed -n '/== toolchain ==/,/== pinned creusot ==/p' "$env_log" | sed '$d' + say " pinned creusot (verification/squabble-creusot/rust-toolchain.toml):" + sed -n '/== pinned creusot ==/,/^$/p' "$env_log" | sed '1d;/^$/d;/^== /d' | sed 's/^/ /' + say "" + say "planned steps:" + say " 01 fmt cargo fmt --all -- --check" + say " 02 check cargo check --workspace --all-targets --locked" + say " 03 clippy cargo clippy --workspace --all-targets --locked -- -D warnings" + say " 04 test cargo test --workspace --locked" + say " 05 test-boj cargo test -p squabble-cli --features boj" + say " 06 mirror cargo test --manifest-path verification/squabble-creusot/Cargo.toml --no-default-features" + say " 07 aspect bash tests/aspect_tests.sh" + say " 08 contract bash contracts/squabble/tests/run-conformance.sh (+ proven-tests)" + if [ "$quick" = false ]; then + say " 09 prove $( [ "$creusot_prove" = true ] && echo 'cd verification/squabble-creusot && cargo creusot prove' || echo 'SKIP — needs --creusot-prove' )" + fi + exit 0 +fi + +# The run ledger is created only once a step is about to run: `--check` reports +# and exits above, so a plan-only invocation leaves no status.tsv behind (an +# empty ledger would be an empty claim). +STATUS_TSV="$out_dir/status.tsv" +: > "$STATUS_TSV" +printf 'step\tstatus\texit\tlog\n' >> "$STATUS_TSV" + +record() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$STATUS_TSV"; } + +# --------------------------------------------------------------- install ---- +installed=true +if [ "$install" = true ] && { ! have cargo || ! have rustup; }; then + say "installing the toolchain via scripts/install-toolchain.sh (verified rustup-init)" + log="$out_dir/00-install.txt" + if bash scripts/install-toolchain.sh --no-creusot >>"$log" 2>&1; then + # shellcheck disable=SC1091 + . "${CARGO_HOME:-$HOME/.cargo}/env" 2>/dev/null || true + record "00-install" PASS 0 "$log" + else + rc=$? + record "00-install" FAIL "$rc" "$log" + installed=false + fi +else + record "00-install" SKIP - - +fi + +# ---------------------------------------------------------------- helpers --- +# run_step