From df5e041a469e8edb3e98caa324f78e8abc250b17 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 1 Nov 2026 17:06:30 +0000 Subject: [PATCH 01/30] proof: canonical-flag receipt, three new Agda modules, repair an unchecked module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROOF-POLICY §2.1 canonical invocation now runs and passes: agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda exit 0, 98s cold, 9 modules + 214 stdlib modules, 0 postulates, 53 -W[no]UnsupportedIndexedMatch warnings at 5 sites. Recorded as a limit (--cubical-compatible is not --cubical; --cubical fails inside stdlib 2.1). - AxiomTrackerCompleteness.agda was outside every gate and did not parse. Fixed (Data.Sum import; clean-left-neutral; new worst-ub-right) and wired into MetaChecker. CI now fails if any module yields no interface. - New: Integrity.agda (E11; proves the invariant dispatch.rs 642/848/998 and solver_integrity.rs 359 violate), ClampTrust.agda (ClampTrustBounds rewrite), Receipt.agda (receipt vs warrant, from epistemic-types@5422cd5). - meta-checker flags in CI; PROOF-STATUS R4 argued->proven plus R9-R11; PROOF-NEEDS + report §10; D9 cross-reference. Grade unchanged at Mathesis Grade 3: no Rocq/Lean leg ran (no reachable release host), so R5 stays open and no leg is reported as passing. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/agda-meta-checker.yml | 30 +- .machine_readable/descriptiles/META.a2ml | 13 +- .machine_readable/root-allow.txt | 1 + .reuse/dep5 | 2 +- CLAUDE.md | 8 + QUICKSTART-USER.adoc | 18 +- README.adoc | 9 +- archive/logtalk/README.adoc | 68 ++ .../logtalk}/security_learning.lgt | 24 + container/compose.toml | 2 +- container/entrypoint.sh | 4 +- container/vordr.toml | 4 +- docs/ARCHITECTURE.adoc | 56 +- docs/DEBT.adoc | 123 +++ docs/ECOSYSTEM-INTEGRATION.adoc | 13 +- docs/ENV-VARS.adoc | 27 + docs/PROOF-NEEDS.adoc | 64 ++ docs/PROOF-STATUS.adoc | 350 ++++++ ...6-11-01-routing-and-rule-engine-recon.adoc | 992 ++++++++++++++++++ .../descriptiles/NEUROSYM.a2ml | 7 +- meta-checker/src/Echidna/AxiomSafety.agda | 31 + .../src/Echidna/AxiomTrackerCompleteness.agda | 37 +- meta-checker/src/Echidna/ClampTrust.agda | 149 +++ meta-checker/src/Echidna/Integrity.agda | 137 +++ meta-checker/src/Echidna/MetaChecker.agda | 60 +- meta-checker/src/Echidna/Receipt.agda | 168 +++ scripts/install-proof-toolchains.sh | 21 + site/docs/api/graphql.adoc | 20 +- site/docs/index.adoc | 9 +- src/interfaces/graphql/main.rs | 12 +- 30 files changed, 2397 insertions(+), 62 deletions(-) create mode 100644 archive/logtalk/README.adoc rename {rules => archive/logtalk}/security_learning.lgt (86%) create mode 100644 docs/PROOF-STATUS.adoc create mode 100644 docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc create mode 100644 meta-checker/src/Echidna/ClampTrust.agda create mode 100644 meta-checker/src/Echidna/Integrity.agda create mode 100644 meta-checker/src/Echidna/Receipt.agda diff --git a/.github/workflows/agda-meta-checker.yml b/.github/workflows/agda-meta-checker.yml index 8ecd16d3..774186e8 100644 --- a/.github/workflows/agda-meta-checker.yml +++ b/.github/workflows/agda-meta-checker.yml @@ -98,10 +98,34 @@ jobs: echo "/tmp/agda-stdlib-2.1/standard-library.agda-lib" > ~/.agda/libraries echo "standard-library" > ~/.agda/defaults - - name: Type-check meta-checker + - name: Type-check meta-checker (PROOF-POLICY §2.1 canonical invocation) if: steps.detect.outputs.relevant == 'true' run: | cd meta-checker echo "$PWD/echidna-meta.agda-lib" >> ~/.agda/libraries - agda src/Echidna/MetaChecker.agda - echo "✓ All 30+ properties machine-verified" + # Canonical invocation, PROOF-POLICY §2.1. --safe is what excludes + # postulates, --type-in-type and primTrustMe; it is not a grep. + # NEVER weaken these flags to get a green run: a failing leg is the + # finding. Note --cubical-compatible is NOT --cubical: the + # -W[no]UnsupportedIndexedMatch warnings this emits mean the flagged + # functions do not compute under transports. Recorded as a limit in + # docs/PROOF-STATUS.adoc R4. + agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda + + - name: Assert the checked closure covers every module + if: steps.detect.outputs.relevant == 'true' + run: | + cd meta-checker + # A module that nothing imports gets no interface and is never + # type-checked — AxiomTrackerCompleteness.agda sat outside the + # closure, and did not parse, until 2026-11-01. Guard against a + # repeat: every source module must yield an interface. + src=$(ls src/Echidna/*.agda | wc -l) + iface=$(find . -name '*.agdai' | wc -l) + echo "source modules: $src, interfaces produced: $iface" + if [ "$src" -ne "$iface" ]; then + echo "::error::${src} modules but ${iface} interfaces — at least one module is outside the checked closure" + ls -1 src/Echidna/*.agda + exit 1 + fi + echo "✓ ${iface} modules type-checked under the PROOF-POLICY §2.1 flags" diff --git a/.machine_readable/descriptiles/META.a2ml b/.machine_readable/descriptiles/META.a2ml index 9efc42ba..35e25613 100644 --- a/.machine_readable/descriptiles/META.a2ml +++ b/.machine_readable/descriptiles/META.a2ml @@ -28,9 +28,16 @@ primary = ["Rust", "Julia", "AffineScript", "Idris2", "Zig"] secondary = ["Chapel", "Bun", "Guix"] [interfaces] -graphql = 8081 -grpc = 50051 -rest = 8000 +# Four listeners, four binaries (recon 2026-11-01; see +# docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc §1). +core-server = 8081 # `echidna server` (--port default); routes /api/* +graphql = 8081 # `echidna-graphql`; POST / = GraphQL, GET / = playground. + # Collides with core-server above. Default kept for + # client compatibility; ECHIDNA_GRAPHQL_ADDR overrides. +grpc = 50051 # `echidna-grpc`; IPv6 loopback only +rest = 8000 # `echidna-rest`; /api/v1/* + /health (ECHIDNA_REST_ADDR) +# standards 0-canon/PORT-REGISTRY.adoc: one port per project, no sharing. The +# 8081 duplication is open debt (docs/DEBT.adoc D8), not an allocation. # ───────────────────────────────────────────────────────────────────────────── # Saturation campaign 2026-06-01 — additive surface declaration diff --git a/.machine_readable/root-allow.txt b/.machine_readable/root-allow.txt index efa51790..4b2d27da 100644 --- a/.machine_readable/root-allow.txt +++ b/.machine_readable/root-allow.txt @@ -1,3 +1,4 @@ .hypatia-ignore CLAUDE.md build/ # build orchestration: guix.scm relocated here (canon 1.2.1 guix-primary template_ref = "build/") +archive/ # quarantine for dead sources (2026-11-01; Logtalk, see archive/logtalk/README.adoc) diff --git a/.reuse/dep5 b/.reuse/dep5 index 25638635..2cd4af30 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -29,7 +29,7 @@ Files: src/* docs-site/* generated/* schemas/* - rules/* + archive/* training_data/* deploy/* container/* diff --git a/CLAUDE.md b/CLAUDE.md index 3919d823..faeb9acc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,14 @@ Guidelines and context for working with Claude Code on the ECHIDNA project. - **Known debt (licence, docs, code)**: [`docs/DEBT.adoc`](docs/DEBT.adoc) - **Environment variables**: [`docs/ENV-VARS.adoc`](docs/ENV-VARS.adoc) - **RSR / CCCP compliance statement**: [`RSR_COMPLIANCE.adoc`](RSR_COMPLIANCE.adoc) +- **Proof and verification policy (normative)**: `3-practice/PROOF-POLICY.adoc` + in [hyperpolymath/standards](https://github.com/hyperpolymath/standards) — + which prover carries which obligation (Rust→Creusot, Ada→SPARK, + APIs/ABIs→Idris 2, theory→Agda rechecked in Rocq and Lean), and the closed + status vocabulary (`proven`, `proven-with-postulates`, `tested-only`, + `argued`, `open`). A change to the mapping is an owner ruling and requires + this pointer to move in the same change. +- **This repo's proof-status ledger**: [`docs/PROOF-STATUS.adoc`](docs/PROOF-STATUS.adoc) - **Receipts for README claims**: [`EXPLAINME.adoc`](EXPLAINME.adoc) - **Contributor guide**: [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md) diff --git a/QUICKSTART-USER.adoc b/QUICKSTART-USER.adoc index 093bf0b2..ccc4378e 100644 --- a/QUICKSTART-USER.adoc +++ b/QUICKSTART-USER.adoc @@ -133,8 +133,22 @@ curl -X POST http://127.0.0.1:8081/api/prove \ The full route table is in `src/rust/server.rs`. Separate GraphQL / gRPC / REST interface crates live under `src/interfaces/` -on dedicated ports (8081 / 50051 / 8000 respectively); they share -the same dispatch path. +(gRPC 50051, REST 8000, GraphQL 8081). They are independent binaries, not +one service on four ports: a GraphQL proof does not take the same path as +`/api/prove` here, and the trust pipeline is reached only by `/api/prove` +in a `--features verisim` build. + +Note that `echidna-graphql` also defaults to 8081, the same port as +`echidna server` above — whichever starts second cannot bind. Run one or +the other, or give GraphQL its own address: + +[source,bash] +---- +ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8082 cargo run -p echidna-graphql +---- + +See `docs/ARCHITECTURE.adoc` §"Client-facing surfaces, and what VeriSimDB +is not" and `docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc`. For a no-build browser UI, open `src/ui/public/prove.html` directly and point the "API base" field at the running server. diff --git a/README.adoc b/README.adoc index 7e2be590..70e04deb 100644 --- a/README.adoc +++ b/README.adoc @@ -190,10 +190,17 @@ outputs. dispatch, including a per-prover cwd/filename-override hook in `+tryProver+` so each backend can be invoked with its required workspace shape (Idris2 / Agda case studies in link:src/chapel/[`+src/chapel/+`]). -* *Three API surfaces*: +* *Three interface binaries*, alongside the core server: ** GraphQL (async-graphql, port 8081) ** gRPC (tonic + Protocol Buffers, port 50051) ** REST (axum + OpenAPI/ReDoc, port 8000) + +These are separate processes with separate route tables, not one service on +four ports. The core `echidna server` is a fourth listener (`/api/*`, port +8081 by default) and the GraphQL binary's default is the same 8081 — start +one or set `ECHIDNA_GRAPHQL_ADDR`. See +link:docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc[the routing +recon]. * *AffineScript / AffineScript UI*: AffineScript components are being ported to AffineScript-TEA as the stdlib primitives needed for the port (Http / Promise / Json / Dict) land in diff --git a/archive/logtalk/README.adoc b/archive/logtalk/README.adoc new file mode 100644 index 00000000..5f459dc2 --- /dev/null +++ b/archive/logtalk/README.adoc @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 ECHIDNA Project Contributors + += Quarantine: Logtalk sources + +*Status*: dead source, retained as a record. *Quarantined*: 2026-11-01. +*Read this first*: nothing here is built, imported, tested or shipped. + +== Contents + +[width="100%",cols="38%,62%",options="header"] +|=== +|File |Status + +|`+security_learning.lgt+` +|DEAD. Was `+rules/security_learning.lgt+`, the only Logtalk module in the +tree (234 lines, two objects, dated 2025-12-29). Body unchanged; a +`+DEAD SOURCE+` header records the evidence. +|=== + +== Why it is here and not in `+rules/+` + +Four questions decided it, and each one alone would have been enough: + +. *Nothing loaded it.* No test, workflow, Justfile recipe, A2ML manifest or + Cargo build step named the file. `+grep -rn '\.lgt' --exclude-dir=.git .+` + returned it and one self-reference inside it. No Logtalk runtime is + installed in CI, and none is declared anywhere. +. *Its input was a machine-local path.* `+cicd_repo_path('~/repos/cicd-hyper-a/.audittraining')+` + — a literal `+~+` in a path the module opens. +. *Its learning step cannot learn.* `+load_training_data/1+` reads Prolog + terms with `+read/2+`, but the catalog it is pointed at is Scheme + (`+ERROR-CATALOG.scm+`). Every term reaches the catch-all clause + `+process_training_term(_)+`, so `+learn_rules/0+` re-asserts the static + tables already in the file. The output is a transcript of the input + constants. +. *The estate moved.* Rule authoring is Elixir in + https://github.com/hyperpolymath/hypatia[hypatia]. ECHIDNA is a Hypatia + *target* (`.hypatia-baseline.json`, `.hypatia-ignore`), never a Logtalk + rule producer. The prose that described Logtalk generation was retired in + `+docs/ECOSYSTEM-INTEGRATION.adoc+` and + `+echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml+` in the + same change. + +`+rules/+` was removed rather than left empty, so that the tree cannot imply +by its shape that ECHIDNA carries rules in Logtalk. + +The full audit, including the path classification and the reproduction +commands, is +link:../../docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`] +§4. + +== What it would take to revive this + +Not a rename and not a wrapper. All five, or it stays dead: + +. A Logtalk or compatible Prolog runtime in CI, pinned the way every other + toolchain in this repo is pinned. +. A parameterised input path — no `+~+`, no home directory. +. A real parser for whatever format the catalog actually is, with a fixture + test per term shape. +. A named consumer in this repository or a sibling, with a contract. +. A reason not to write it in Elixir like the rest of the estate's rules. + +== Licence + +MPL-2.0, unchanged, per the `+SPDX-License-Identifier+` header in the file +and the `+Files: archive/*+` stanza in `+.reuse/dep5+`. diff --git a/rules/security_learning.lgt b/archive/logtalk/security_learning.lgt similarity index 86% rename from rules/security_learning.lgt rename to archive/logtalk/security_learning.lgt index 86f0250f..baa21052 100644 --- a/rules/security_learning.lgt +++ b/archive/logtalk/security_learning.lgt @@ -1,4 +1,28 @@ %% SPDX-License-Identifier: MPL-2.0 +%% +%% =========================================================================== +%% DEAD SOURCE — quarantined 2026-11-01, body unchanged. +%% +%% This file was `rules/security_learning.lgt`, the only Logtalk module in the +%% ECHIDNA tree. Nothing loaded it: no test, workflow, Justfile recipe, A2ML +%% manifest or Cargo build step referenced it, and no Logtalk runtime is +%% installed anywhere in CI. Its input path was a literal `~/repos/...` on one +%% machine, and its "learning" step reads Prolog terms from a Scheme catalog +%% (ERROR-CATALOG.scm), so every term falls through to the catch-all clause and +%% the learned rules are a re-assertion of the static tables below. +%% +%% Estate rule authoring moved to Elixir rule modules in Hypatia. ECHIDNA is a +%% Hypatia *target* (.hypatia-baseline.json, .hypatia-ignore), not a rule +%% producer — the cicd-hyper-a alert taxonomy this file encodes is no longer +%% the path by which ECHIDNA-side rules reach the estate. +%% +%% Kept rather than deleted so the record is byte-recoverable. Do not add to +%% it, do not wire it up. Reviving it needs, at minimum: a runtime in CI, a +%% parameterised input path, a real parser for the catalog format, and a +%% consumer. See ./README.adoc and +%% docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc §4. +%% =========================================================================== +%% %% ECHIDNA Security Rule Learning Module %% Learns security patterns from cicd-hyper-a audit data diff --git a/container/compose.toml b/container/compose.toml index 4d3880ab..572f0d49 100644 --- a/container/compose.toml +++ b/container/compose.toml @@ -24,7 +24,7 @@ environment = { } volumes = ["echidna-data:/data"] restart = "always" -healthcheck = { test = "curl -sf http://localhost:8081/health", interval = "30s", timeout = "5s", retries = 3 } +healthcheck = { test = "curl -sf http://localhost:8081/api/health", interval = "30s", timeout = "5s", retries = 3 } [services.svalinn] image = "ghcr.io/hyperpolymath/svalinn:latest.ctp" diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 1ea43dd8..8886c820 100755 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -22,4 +22,6 @@ if [ -d "${APP_DATA_DIR:-/data}" ]; then fi fi -exec /app/bin/echidna serve --host "${APP_HOST:-[::]}" --port "${APP_PORT:-8081}" "$@" +# Subcommand is `server`, not `serve` (clap enum in src/rust/main.rs; there is +# no alias). Fixing this on 2026-11-01 — every container start failed at exec. +exec /app/bin/echidna server --host "${APP_HOST:-[::]}" --port "${APP_PORT:-8081}" "$@" diff --git a/container/vordr.toml b/container/vordr.toml index d336e420..deeb4345 100644 --- a/container/vordr.toml +++ b/container/vordr.toml @@ -7,11 +7,11 @@ name = "echidna" version = "1.5.0" [health] -endpoint = "http://localhost:8081/health" +endpoint = "http://localhost:8081/api/health" interval = "30s" timeout = "5s" failure_threshold = 3 -readiness_endpoint = "http://localhost:8081/ready" +readiness_endpoint = "http://localhost:8081/api/health" readiness_timeout = "10s" on_failure = "alert" diff --git a/docs/ARCHITECTURE.adoc b/docs/ARCHITECTURE.adoc index 58afe77c..1e95eebb 100644 --- a/docs/ARCHITECTURE.adoc +++ b/docs/ARCHITECTURE.adoc @@ -31,8 +31,8 @@ independently reproduced where formats allow (Alethe, DRAT/LRAT, TSTP). │ Rust Core (src/rust/, crates/) │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ │ -│ │ CLI/REPL │ │ REST / GraphQL│ │ gRPC │ │ FFI (Zig) │ │ -│ │ (main.rs)│ │ (axum :8000) │ │ (tonic :50051)│ │ (16 exports) │ │ +│ │ CLI/REPL │ │ REST │ │ gRPC │ │ FFI (Zig) │ │ +│ │ (main.rs)│ │ (axum :8081) │ │ (tonic :50051)│ │ (16 exports) │ │ │ └────┬─────┘ └──────┬────────┘ └──────┬────────┘ └──────┬───────┘ │ │ └────────────────┴──────────────────┴────────────────┘ │ │ │ │ @@ -103,7 +103,14 @@ link:handover/TODO.adoc[`+handover/TODO.adoc+`] for current state): [arabic] . *Integrity* (`+integrity/+`) — solver binary SHAKE3-512 + BLAKE3 -against `+config/solver-manifest.toml+`. +against `+config/solver-manifest.toml+`. *Caveat, 2026-11-01*: this step +runs only when a checker is attached, and nothing in the tree calls +`+with_integrity_checker+`; unconfigured, the pipeline treats integrity as +passing (`+dispatch.rs:632+`). The manifest also still holds +`+PLACEHOLDER_COMPUTE_ON_FIRST_RUN+` for every solver. Treat this step as +*not executed* until it is wired; see +link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`] +§6 RD5. . *Dispatch* (`+dispatch.rs+`) — `+ProverDispatcher::select_prover+` picks the backend; under `+with_verisim+`, `+VeriSimAdvisor+` queries `+mv_prover_success_by_class+` for historical success-rate hints. @@ -127,6 +134,49 @@ updates. `+with_verisim_writer+`) — fire-and-forget write to VeriSimDB `+proof_attempts+`, closes the learning loop. +=== Client-facing surfaces, and what VeriSimDB is not + +There are *four independent listeners*, not one service on four ports. Each is +a separate binary with its own route table and its own path to the provers: + +[width="100%",cols="30%,16%,54%",options="header",] +|=== +|Listener |Default bind |Route root + +|`+echidna server+` |`+127.0.0.1:8081+` |`+/api/*+` — `/api/health`, +`+/api/provers+`, `/api/prove`, `/api/verify`, `/api/session/*`, … +|`+echidna-rest+` |`+127.0.0.1:8000+` |`+/api/v1/*+` plus root `+/health+` +|`+echidna-graphql+` |`+127.0.0.1:8081+` |`+POST /+` (GraphQL), `+GET /+` +(playground), `+/health+` +|`+echidna-grpc+` |`+[::1]:50051+` |`+ProofService+` over `+echidna.proto+` +|=== + +`+echidna server+` and `+echidna-graphql+` share a default port; start only one +without setting `+ECHIDNA_GRAPHQL_ADDR+`. The one-port-per-project rule is +`+0-canon/PORT-REGISTRY.adoc+` in hyperpolymath/standards; the collision is +open debt. + +*VeriSimDB is a persistence and learning adapter, not a communication path.* +It sits behind the non-default `+verisim+` Cargo feature, is written to +fire-and-forget after a dispatch, and is never consulted to reach a prover. +Clients do not route through it, and *must not* need to: the estate's +`+3-practice/PROOF-POLICY.adoc+` §2.2 states that ECHIDNA is "`the +longer-term automation destination, not a prerequisite for current independent +checks`". A mandatory VCL hop would violate that. Two consequences worth +knowing before you deploy: + +* Enabling `+verisim+` also moves `+POST /api/prove+` onto `+ProverDispatcher+` + (`+server.rs:423+`); without it, `/api/prove` goes to the backend factory + directly. A storage feature should not decide the verification route. +* `/api/verify` never enters the dispatcher at all, and `/api/verify_raw` + executes the solver binary directly by design. + +So "which pipeline ran" depends on the surface and the feature set. If a client +needs a machine-readable answer it can trust, use `+echidna prove +--output json+` — the documented +link:PROVE-RESULT-CONTRACT.adoc[`+echidna.prove.result/1+`] contract — rather +than inferring it from an HTTP response body. + === Polyglot source layout `+src/+` holds one subdirectory per language. The split is intentional — diff --git a/docs/DEBT.adoc b/docs/DEBT.adoc index 545a0841..82daa8fe 100644 --- a/docs/DEBT.adoc +++ b/docs/DEBT.adoc @@ -421,6 +421,129 @@ git grep -cE 'todo!\(\)|unimplemented!\(\)' -- '*.rs' | awk -F: '{s+=$2} END{pri ''''' +=== Routing and verification claims (recon 2026-11-01) + +Source: +link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`]. +Entries here are the ones that outlive that report. + +==== D8. Core server and GraphQL interface both default to port 8081 + +*P1.* Two binaries, one default port. `+echidna server+` takes `+--port+` +default 8081; `+echidna-graphql+` hard-coded 8081 +(`+src/interfaces/graphql/main.rs+`). Whichever starts second fails to bind. + +This is not merely untidy: `0-canon/PORT-REGISTRY.adoc` in +hyperpolymath/standards states "Every project MUST use a unique port. No two +projects share a port." + +Fixed so far: `+ECHIDNA_GRAPHQL_ADDR+` override (default unchanged, so no +client breaks) and corrections to `+QUICKSTART-USER.adoc+`, `+README.adoc+`, +`+docs/ARCHITECTURE.adoc+`, `+site/docs/index.adoc+`, +`+site/docs/api/graphql.adoc+` and `+.machine_readable/descriptiles/META.a2ml+`, +which variously omitted the core server or claimed the three interfaces sat +"on dedicated ports". + +Open: the default itself. Moving it needs a release note, since anything +pinned to 8081 keeps working only while the two are not co-resident. + +[source,bash] +---- +grep -n 'default_value = "8081"' src/rust/main.rs +grep -n 'ECHIDNA_GRAPHQL_ADDR' src/interfaces/graphql/main.rs +---- + +==== D9. Solver integrity is documented as enforced and is not enforced + +*P1.* `+docs/ARCHITECTURE.adoc+` and the published site listed solver binary +integrity as step 1 of the trust pipeline. The check is conditional +(`+dispatch.rs:632+`): with no checker attached, `+solver_integrity_ok` is +`true`. `+with_integrity_checker+` has no caller in the tree, and +`+config/solver-manifest.toml+` holds `+PLACEHOLDER_COMPUTE_ON_FIRST_RUN+` for +every solver, so attaching one today would report `+Uninitialized+` — which the +code also treats as passing. + +Fixed so far: the docs and the published site now say "capability present, not +currently engaged" instead of claiming a step that does not run. + +Now machine-checked on the specification side: +`+meta-checker/src/Echidna/Integrity.agda+` (`+docs/PROOF-STATUS.adoc+` R9) +proves that verification requires an initialised manifest and that an +uninitialised manifest never verifies — with collision resistance carried as an +explicit hypothesis, and a sharpness result showing that hypothesis cannot be +dropped. The accepting call sites are `+dispatch.rs+` 642, 848, 998 and +`+solver_integrity.rs+` 359. So the divergence is checked on one side and read on +the other, rather than read on both: closing D9 means changing those four lines, +not the proof. + +Open: decide whether to wire it (introducing a refuse-on-mismatch failure mode) +or to delete the step from the pipeline description. Recorded rather than +silently changed. + +[source,bash] +---- +grep -rn 'with_integrity_checker' src/ crates/ tests/ # definition only +grep -n 'PLACEHOLDER' config/solver-manifest.toml +---- + +==== D10. `+--features verisim+` changes verification routing, not just storage + +*P1.* `+POST /api/prove+` compiles two ways: through `+ProverDispatcher+` with +the feature, straight to the backend factory without it (`+server.rs:423+`). +A persistence flag therefore decides whether an HTTP proof is arbitrated, and +no response field distinguishes the two. + +Open: build the dispatcher unconditionally and make `+with_verisim_writer+` +purely additive. Not done here because it changes what a default build +returns — it needs a decision record. + +[source,bash] +---- +sed -n '420,450p' src/rust/server.rs +---- + +==== D11. `+echidna-mcp+` calls a subcommand that does not exist + +*P2.* `+crates/echidna-mcp/src/main.rs+` runs `+echidna check-prover +`; +`+Commands+` has no such variant, so the tool fails with a usage error every +time. Its `+prove+` tool also uses the legacy `+--format json+` dialect +(`+{"level":"success"}+`) instead of the `+--output json+` prove contract. + +Open: needs either a `+check-prover+` subcommand or a `/api/provers` probe, plus +a rewrite onto the contract. Feature decision. + +[source,bash] +---- +grep -n 'check-prover' crates/echidna-mcp/src/main.rs # one call site +grep -n 'CheckProver\|check-prover' src/rust/main.rs # expect empty +---- + +==== D12. `+docs-site/+` is unbuilt and its API pages are wrong + +*P2.* Nothing builds or deploys `+docs-site/+`: `+pages.yml+` publishes +`+site/+` through Ddraig. Its pages still claim a `+/graphql+` endpoint and a +`+/playground+` (the binary serves both at `+/+`), "30 supported prover +backends", and a `+localhost:8000/api/v1+` base. + +Open: delete the tree or regenerate it from `+site/docs/+`. Left in place here +because removing a documentation tree is an owner decision, not a cleanup. + +==== Fixed en route (recorded so they are not re-triaged) + +* `+container/entrypoint.sh+` executed `+echidna serve+`; the subcommand is + `+server+`. Every container start failed at `+exec+`. +* `+container/compose.toml+` healthcheck probed `+/health+` and + `+container/vordr.toml+` probed `+/health+` and `+/ready+`; the core server + serves `+/api/health+` and never had `+/ready+`. +* `+rules/security_learning.lgt+` was dead Logtalk, loaded by nothing; + quarantined to `+archive/logtalk/+` with a `+DEAD SOURCE+` header. See that + directory's README. +* `+docs/ECOSYSTEM-INTEGRATION.adoc+` and + `+echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml+` claimed + Hypatia emits Logtalk rules. It emits Elixir rules. + +''''' + === Already tracked as issues Open issues covering debt not duplicated above: diff --git a/docs/ECOSYSTEM-INTEGRATION.adoc b/docs/ECOSYSTEM-INTEGRATION.adoc index 80eefd23..00ae7aa1 100644 --- a/docs/ECOSYSTEM-INTEGRATION.adoc +++ b/docs/ECOSYSTEM-INTEGRATION.adoc @@ -114,8 +114,17 @@ cross-repo dispatch *How It Works*: 1. Fleet-coordinator runs Hypatia scanner on repos 2. Hypatia generates findings in shared context 3. Learning engine observes -patterns (unsafe_panic, eval_usage, etc.) 4. Auto-generates Logtalk -rules after 5+ observations 5. Proposes new detection rules via PRs +patterns (unsafe_panic, eval_usage, etc.) 4. Emits Elixir rule modules +after 5+ observations 5. Proposes new detection rules via PRs + +*Correction (2026-11-01)*: this step previously said "Auto-generates +Logtalk rules". Hypatia's rule engine is Elixir, and ECHIDNA is a Hypatia +*target* — `.hypatia-baseline.json` and `.hypatia-ignore` — not a Logtalk +rule producer. The one Logtalk module that ever lived here +(`rules/security_learning.lgt`) was loaded by nothing and is quarantined at +`archive/logtalk/`. See +link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`] +§4. *Command*: diff --git a/docs/ENV-VARS.adoc b/docs/ENV-VARS.adoc index 62013464..e2714cc3 100644 --- a/docs/ENV-VARS.adoc +++ b/docs/ENV-VARS.adoc @@ -26,6 +26,33 @@ surfaces using `+VERISIMDB_URL+` are stale and being migrated. separate from the ML server only when the GNN runs on a different host. |=== +=== Interface listeners + +Each listener is a separate binary. Two of them default to the same port; see +link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`] +§6 RD2 and `+0-canon/PORT-REGISTRY.adoc+`. + +[width="100%",cols="20%,20%,20%,20%,20%",options="header",] +|=== +|Variable |Type |Default |Used by |Purpose +|`+ECHIDNA_REST_ADDR+` |host:port |`+127.0.0.1:8000+` +|`+src/interfaces/rest/main.rs:142+` |Bind address for the `+echidna-rest+` +binary (`+/api/v1/*+`). Not the core server. + +|`+ECHIDNA_GRAPHQL_ADDR+` |host:port |`+127.0.0.1:8081+` +|`+src/interfaces/graphql/main.rs+` |Bind address for `+echidna-graphql+`. +Same default as the core server's `+--port+`, so set this when running both. + +|`+--port+` / `+--host+` (CLI flags, not env) +|host:port |`+127.0.0.1:8081+` +|`+src/rust/main.rs+` |Bind address for `+echidna server+`. There is no +environment override for this one. + +|`+APP_PORT+` / `+APP_HOST+` |host:port |`+[::]` / `+8081+` +|`+container/entrypoint.sh+` |Read by the container entrypoint and passed to +`+echidna server+` as flags. The binary itself does not read them. +|=== + === Training & evaluation (Julia) [width="100%",cols="20%,20%,20%,20%,20%",options="header",] diff --git a/docs/PROOF-NEEDS.adoc b/docs/PROOF-NEEDS.adoc index a9143ac5..d09809af 100644 --- a/docs/PROOF-NEEDS.adoc +++ b/docs/PROOF-NEEDS.adoc @@ -296,3 +296,67 @@ representation / transport* (encoding shapes for Http / Promise / JSON / Dict). It carries no L3 obligation (no claim about fibre structure of the wire transport), so audit-and-not-relevant applies. Recorded here so a future port reviewer does not need to re-audit. + +=== Proof updating, 2026-11-01 + +First session in which the Agda corpus was type-checked under the +`+PROOF-POLICY+` §2.1 canonical invocation +(`--safe --without-K --cubical-compatible`) rather than plain `agda`. Receipt: +exit 0, 98 s cold, 9 modules, 214 stdlib modules, 53 warnings all of one class. +Status ledger: `+docs/PROOF-STATUS.adoc+` R4, R9, R10, R11. + +==== What changed + +[width="100%",cols="26%,16%,58%",options="header"] +|=== +|Module |Before |Now + +|`+AxiomTrackerCompleteness.agda+` +|**Outside the checked closure and did not parse.** Nothing imported it, no +workflow named it, so no gate had ever compiled it. +|In the closure. Two defects fixed: a hand-rolled `+_⊎_+` was being declared in +a `+where+` clause attached to a type signature (a syntax error — now imported +from `+Data.Sum+`), and two proofs relied on `+worst-policy Clean b+` reducing +when `+worst-policy+` splits on its **first** argument, so the term was stuck +(now via `+clean-left-neutral+` and the new `+worst-ub-right+` in +`+AxiomSafety.agda+`). + +|`+Integrity.agda+` +|did not exist; E11 had prose only +|States E11 and proves it: verification requires an initialised manifest, a +substituted binary is never verified, an uninitialised manifest never verifies. +Collision resistance is a record field, and `+sharpness+` shows it cannot be +inhabited for an accept-everything comparator — so the hypothesis is +load-bearing. + +|`+ClampTrust.agda+` +|did not exist; the obligations lived only in +`+verification/proofs/idris2/ClampTrustBounds.idr+`, an out-of-policy corpus +with no recorded run +|Bounds, monotonicity, fixed points and idempotence, restated over the +trust-level type the gate checks. Monotonicity by step lemma plus transitivity, +not the Idris2 file's decision table. + +|`+Receipt.agda+` +|did not exist +|Receipt/warrant separation as a two-world Kripke countermodel: receipts are +factive, warrants are not, and no warrant promotes to a receipt. Carried over +from `+epistemic-types@5422cd5+` by semantic rewrite; that repo keeps ownership +of its own theorems (`+PROOF-POLICY+` §2.2). + +|`+MetaChecker.agda+` +|claimed "30+ proven properties, 0 postulates (except funext in AxiomSafety)" +|Summary corrected: the `+funext+` clause was stale (no postulates anywhere), +the property count was never derived from a count, and the canonical invocation +plus its `+UnsupportedIndexedMatch+` limit are now recorded in the header. +|=== + +==== What was not run, and why (PROOF-POLICY §6) + +No Rust, Creusot, Lean 4, Idris 2, Rocq, Z3 or cvc5 leg was executed. The +sandbox this was done in could reach only GitHub, PyPI and npm; GitHub *release +assets* (the only source of the Agda/Lean/Idris2 tarballs) were blocked, and +`+scripts/install-proof-toolchains.sh+` needs root, apt and that same host. So +R1, R2, R3, R5 and every Stage-8a Idris2 obligation remain exactly as recorded +— `+open+` or `+argued+` — and no leg is reported as passing. Reporting them as +passing because the flags were "the right ones" would be the failure §6 names. diff --git a/docs/PROOF-STATUS.adoc b/docs/PROOF-STATUS.adoc new file mode 100644 index 00000000..07701b3e --- /dev/null +++ b/docs/PROOF-STATUS.adoc @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 ECHIDNA Project Contributors + += Proof status — policy-vocabulary view + +*Status*: current as of 2026-11-01, commit `+916b769+`. +*Normative policy*: `+3-practice/PROOF-POLICY.adoc+` in +https://github.com/hyperpolymath/standards[hyperpolymath/standards] (owner +ruling 2026-10-26). + +This is ECHIDNA's *proof-status file* in the sense `+PROOF-POLICY+` uses the +term: the place each obligation's ledger row lives, in the closed vocabulary of +§5. It is a *view* over link:PROOF-NEEDS.adoc[`+docs/PROOF-NEEDS.adoc+`], not a +replacement: + +[width="100%",cols="26%,74%",options="header"] +|=== +|File |Carries + +|`+docs/PROOF-NEEDS.adoc+` +|The register as it grew: per-obligation narrative, which artefact covers +what, and the dated build receipts (`lake build` run 2026-05-18, per-file error +counts, root causes). *Authoritative for detail and for failures.* + +|`+docs/PROOF-STATUS.adoc+` (this file) +|The same obligations mapped onto `+PROOF-POLICY+` §1 (which verifier carries +which kind) and §5 (the closed status vocabulary), plus obligations +`+PROOF-NEEDS+` does not cover. *Authoritative for the status token.* + +|=== + +Where the two appear to disagree, this file's *token* wins and +`+PROOF-NEEDS+`'s *narrative* wins, because neither a token nor a narrative can +be more accurate than the receipt under it. + +== Vocabulary + +Closed set, `+PROOF-POLICY.adoc+` §5. Machine-readable ledgers use the +lowercase token; prose may use the label. + +[width="100%",cols="22%,16%,62%",options="header"] +|=== +|Token |Label |Means + +|`+proven+` |PROVEN +|Machine-checked with no escape hatch, trust boundary named. A bounded check is +not this row. + +|`+proven-with-postulates+` |PROVEN-WITH-AXIOMS +|A kernel checked it and it rests on named necessary axioms, listed here. + +|`+tested-only+` |TESTED +|Checked on a bounded or sampled domain; bounds given. + +|`+argued+` |ASSUMED +|A stated hypothesis, or a transcription no kernel has checked. Includes "the +CI gate exists and the authors say it passes" when no receipt is cited. + +|`+open+` |OPEN +|Not done. +|=== + +=== Relation to the Mathesis grades + +https://github.com/hyperpolymath/mathesis[mathesis], *Estate-wide Mathematical +Knowledge Architecture* §5, grades a *theorem* by how far its witness has +travelled: **Grade 3** type-checks locally in its home system and is not +translated; **Grade 2i/2ii** one / two independent translations with statement +correspondence recorded; **Grade 1** machine-checked through a verified checker +API in headless CI. + +The two vocabularies are orthogonal — one grades a theorem's reach, the other +records whether a repository's obligation was discharged — but they interact: + +* A home-system leg may be `+proven+` while every independent leg is `+open+`. + That combination *is* Grade 3, and it is the ceiling for anything in this + repository. +* A translation leg is `+argued+`, not `+proven+`, until the statement + correspondence is recorded (`+PROOF-POLICY+` §2: "`the kernel checks the term + only against the translated statement`"). + +Mathesis §6 forbids flattening assurance into a percentage. See R5 and R6. + +== Obligations + +Grouped by the `+PROOF-POLICY+` §1 mapping, because that is what decides which +verifier is even allowed to carry the row. + +=== Rust — Creusot (§1: first-party Rust) + +[width="100%",cols="24%,13%,38%,25%",options="header"] +|=== +|Obligation |Row |Basis |Evidence / next step + +|R1. Trust pipeline, dispatch, arbitration, Pareto +|`+open+` +|`+PROOF-POLICY+` §1 maps first-party Rust to Creusot. +|`+crates/echidna-core-creusot+` describes itself as "`Creusot-annotated (not +yet proved)`"; the `+creusot+` dev-dependency is commented out and the crate +builds on stable Rust behind a `+creusot+` feature. Annotation is not proof. Row +stays `+open+` until a Creusot run yields a receipt with a commit hash. + +|=== + +=== Idris 2 — APIs, ABIs, engineering properties (§1) + +`+PROOF-NEEDS+` lists E2, E3, E5, E6, E7, E12, the VQL-UT row, the extensive +ABI row and the Stage-8a trio as constructively proved, all in +`+verification/proofs/idris2/+` or `+src/abi/+`, all with zero `+believe_me+` +(the `+PROOF-NEEDS+` audit of 2026-05-18 establishes that the 21 +`+believe_me+` grep hits are prose and detector strings). + +[width="100%",cols="24%,13%,38%,25%",options="header"] +|=== +|Obligation |Row |Basis |Evidence / next step + +|R2. Idris 2 interface/ABI obligations (E2, E3, E5, E6, E7, E12, Stage 8a, +VQL-UT, extensive ABI) +|`+argued+` +|`+PROOF-POLICY+` §1 maps ABIs and engineering properties to Idris 2. The +artefacts exist and are detailed in `+PROOF-NEEDS+`. +|No `+idris2 --build+` receipt for +`+verification/proofs/idris2/echidna-verif-proofs.ipkg+` is cited with a commit +hash. Promote to `+proven+` by citing a green run and its commit — that is a +one-line change, and it is the cheapest upgrade in this file. + +|=== + +=== Lean 4 — cross-checks and algebraic properties (§4 exceptions) + +[width="100%",cols="24%,13%,38%,25%",options="header"] +|=== +|Obligation |Row |Basis |Evidence / next step + +|R3. Lean 4 legs: E4-L4 `+ConfidenceLattice.lean+`, E10 +`+ParetoMaximality.lean+` / `+ParetoStrongMaximality.lean+`, E11 +`+IntegrityVerification.lean+`, E13 `+PortfolioCompleteness.lean+` +|`+open+` +|Directly recorded: `+lake build+` run 2026-05-18 (Lean 4.13.0) — *all five +fail to compile*, 49 / 2 / 1 / 5 / 31 errors, three root causes +(`+unfold_let+` removed in 4.13.0; Mathlib tactics under a zero-mathlib +lakefile; instance synthesis and module resolution). +|Nothing. This row is correct and must not be softened. `+PROOF-NEEDS+` records +that `+ConfidenceLattice.lean+` was left byte-identical and that the +`+unfold_let+` token-swap made it worse (104 errors). The open decision is +Mathlib-vs-core, which is an owner call. + +|=== + +=== Agda — theory and metatheory (§1, §2) + +[width="100%",cols="24%,13%,38%,25%",options="header"] +|=== +|Obligation |Row |Basis |Evidence / next step + +|R4. Agda meta-checker: trust-level order, axiom safety, portfolio, dispatch, +axiom-tracker completeness, integrity, clamp bounds, receipt/warrant (9 modules) +|`+proven+` +|First-party Agda in `+meta-checker/src/+`, type-checked with the §2.1 +canonical invocation. Receipt taken 2026-11-01 at commit `+916b769+`: + +---- +agda --safe --without-K --cubical-compatible \ + src/Echidna/MetaChecker.agda # exit 0, 98 s cold +---- + +Versions: Agda `+2.7.0.1+`, `+agda-stdlib+` `+2.1+` (214 stdlib modules pulled +in). All 9 modules in `+src/Echidna/+` produce an interface; 53 warnings, all +of them `+-W[no]UnsupportedIndexedMatch+`, at five sites (`+TrustLevel+` 77, +88, 235; `+AxiomSafety+` 119; `+ClampTrust+` 98). No `+postulate+`, no +`+sorry+`, no `+believe_me+`, no `+Admitted+`; `+--safe+` is what excludes +them rather than a `+grep+`. +|Trust boundary, named: the Agda 2.7.0.1 kernel under `+--safe+`, plus the +stdlib. **Limit, recorded not hidden:** those warnings mean the flagged +functions do not *compute* under Cubical transports. The corpus is +`+--cubical-compatible+`, not `+--cubical+` — exactly the distinction +`+PROOF-POLICY+` §2.1 draws — and dropping the warning, or the flag, to get a +clean log would be the weakening §2.1 forbids. It is immaterial today because +nothing transports these terms; transport is R5's job, which is where the +limit would first bite. + +**Why not just use `+--cubical+`?** Because it fails, and not on this corpus. +`+agda --cubical --safe src/Echidna/MetaChecker.agda+` exits non-zero in +`+agda-stdlib-2.1/src/Data/List/Membership/Setoid/Properties.agda+` with +unsolved metas, before any ECHIDNA module is reached: stdlib 2.1 is written to +be `+--cubical-compatible+`, and ships +`+--warning=noUnsupportedIndexedMatch+` in `+standard-library.agda-lib+` for +precisely this reason. So `+--cubical-compatible+` is not a weakened choice here; +it is the strongest flag this dependency set supports, and the policy's own +distinction between the two flags is what lets that be said plainly. + +Promoted from `+argued+` on the strength of that receipt. Before it, the row +rested on "the CI gate exists and the authors say it passes" — which §5 defines +as `+argued+`, not a receipt. One module +(`+AxiomTrackerCompleteness.agda+`) turned out never to have been in the +checked closure at all, and did not type-check (see R9's note on scope). + +|R9. E11 solver-integrity soundness +|`+proven+` +|`+meta-checker/src/Echidna/Integrity.agda+`, inside the R4 receipt. States the +obligation that had no Agda record: verification requires an initialised +manifest; verification means the recorded digest was matched; a substituted +binary is never verified; an uninitialised manifest never verifies. Collision +resistance is a record *field*, not a postulate, so the guarantee is +conditional and says so in its type; `+sharpness+` exhibits a comparator for +which that field cannot be inhabited, so the hypothesis is load-bearing rather +than decorative. +|**Specification, not implementation.** `+src/rust/dispatch.rs+` lines 642, 848 +and 998, and `+src/rust/integrity/solver_integrity.rs+` line 359, all accept +`+IntegrityStatus::Uninitialized+` as passing. The theorem proves the opposite +of what that code does. That divergence is DEBT D9, now machine-checked on one +side instead of argued on both. Closing R9's implementation leg means changing +the Rust, not the proof. + +|R10. ClampTrustBounds +|`+proven+` +|`+meta-checker/src/Echidna/ClampTrust.agda+`, inside the R4 receipt. Restates +the four obligations of `+verification/proofs/idris2/ClampTrustBounds.idr+` +(bounds, monotonicity, fixed points, and — added — idempotence, i.e. that the +clamp lands on a real level rather than between two) over the trust-level type +the gate actually checks. +|The Idris2 original stays *out of policy* per §3 and, as far as this +repository can tell, was never run: `+idris2-abi-ci.yml+` cites it but no +receipt is recorded. This row does not validate that file; it discharges the +same obligations in a corpus that is checked. Monotonicity is by step lemma +plus transitivity rather than the Idris2 file's 36-clause decision table. + +|R11. Epistemic separation: receipt vs warrant +|`+proven+` +|`+meta-checker/src/Echidna/Receipt.agda+`, inside the R4 receipt. A two-world +Kripke countermodel: a receipt is factive, a warrant is not; the same warrant +never licences the stronger token; while the agent cannot discriminate the +worlds, no receipt exists at all; a discriminating check parameter yields one. +This is what makes §5's "`+argued+` is not `+proven+`" a theorem instead of a +style rule. +|Semantic rewrite of the receipt/warrant distinction from +https://github.com/hyperpolymath/epistemic-types[epistemic-types] at +`+5422cd5+`, **not** a port of its theorems. For the record, that source is +itself green under the same invocation (22/22 modules, exit 0, 0 postulates), so +this row is a rewrite of something that checks, not of an aspiration: per §2.2 the source project keeps +ownership of those, and this module proves only what ECHIDNA needs. The +residual limit is the honest one — a receipt requires a check that +*discriminates*, which is precisely the evidence R5 does not yet have. + +|R5. Independent recheck of R4 in Rocq *and* Lean +|`+open+` +|`+PROOF-POLICY+` §2: every Agda theory proof is rechecked in both, transported +through Dedukti or re-proved, with the route recorded per theorem. +|Never attempted. This is what holds the set at Mathesis *Grade 3*: locally +checked, untranslated. ECHIDNA's `+src/rust/exchange/+` speaks Dedukti, so the +transport route exists; using it is unstarted work, not a missing capability. + +|R6. E9 "proof composition soundness" +|`+argued+`, with a location defect +|`+PROOF-NEEDS+` evidences it with `+proofs/agda/ProofComposition.agda+`. But +`+PROOF-POLICY+` §3 says `+proofs//+` trees are fixtures, "`outside +this policy`". +|The obligation survives; its evidence sits where the policy says evidence +cannot live. **New (2026-11-01):** the whole `+proofs/agda/+` tree — 13 files +including `+ProofComposition.agda+` — type-checks under +`+agda --safe --without-K --cubical-compatible+`, 13/13, no postulates. So the +cheap remedy is the *move* to a policy-conformant location +(`+verification/proofs/agda/+`), not re-stating the obligation as unsupported: +the evidence is good, it is merely filed where §3 says evidence cannot live. +Recorded, not resolved — moving it is a repository change with cross-references +to update, so it is left as a named action rather than done silently. + +|=== + +=== Calibrated scores (§5 is about proof, not ranking) + +[width="100%",cols="24%,13%,38%,25%",options="header"] +|=== +|Item |Row |Basis |Note + +|R7. `+verification/confidence.rs+` 0–1 score; `+DispatchResult.trust_level+` 1–5 +|`+tested-only+` (internal ranking) — *not an assurance claim* +|Mathesis §6: "`Do not flatten proof confidence into a percentage.`" +|The score ranks backends against each other and is unweighted by any receipt. +It is a routing heuristic. Do not publish it as assurance and do not compare it +with a Mathesis grade. + +|R8. `+echidna.prove.result/1+` `+trust.confidence+` +|`+open+`, correctly +|Receipt/warrant discipline: a receipt is transported proof, a warrant is only +evidence. +|A single-backend `+echidna prove+` verifies no certificate, so the field is +`+null+` on every run today. That is the honest value, not a gap to fill with a +guess. + +|=== + +== Not obligations + +`+PROOF-POLICY+` §3: "`Corpora that exist to exercise provers are not +obligations. echidna's `+proofs//+` trees and similar fixtures are test +material for a multi-prover system and are outside this policy.`" + +So the sixteen `+proofs//+` directories and `+training_data/+` carry no +row. They exercise parsers and backends. Listing them is deliberate: an absent +row should read as *out of policy*, not *forgotten*. The same holds for +`+archive/logtalk/+` (dead, quarantined) and for `+echidna-playground/+`. + +Note the tension with R6 — one artefact in a corpus directory is cited by +`+PROOF-NEEDS+` as covering a real obligation. §3 is why that is a defect in +placement rather than a status question. + +== Standing assumptions + +Recorded per `+PROOF-POLICY+` §1.1: where a first-party claim leans on +something the estate does not own, the reliance is its own `+argued+` row +naming the dependency. + +[width="100%",cols="30%,14%,56%",options="header"] +|=== +|Assumption |Row |Scope + +|Solver binaries behave as their published versions do +|`+argued+` +|See link:DEBT.adoc[`+docs/DEBT.adoc+`] D9. No integrity checker is attached in +this release and `+config/solver-manifest.toml+` holds +`+PLACEHOLDER_COMPUTE_ON_FIRST_RUN+` for every solver, so *which* binary ran is +assumed, not checked. Every end-to-end result in this repository inherits it. + +|VeriSimDB persists what it is sent, when it is sent +|`+argued+` +|The writer is fire-and-forget by design (`+dispatch.rs:725+`); a dropped record +is invisible to the proof result. Fine for a learning loop, and precisely why +it must never become a dependency of one. + +|=== + +== What this file does not do + +It is not a release gate and not a CI result. A `+proven+` row here would never +mean "ECHIDNA is verified" — it would mean one named obligation was discharged +by one named tool with its boundary written down. Nothing here vouches for a +prover backend's own soundness. + +No row in this file is above `+argued+` today, and that is the accurate state, +not an omission. + +== Related + +* `+3-practice/PROOF-POLICY.adoc+`, `+docs/TRUSTED-BASE-REDUCTION-POLICY.adoc+`, + `+0-canon/PORT-REGISTRY.adoc+` — hyperpolymath/standards, normative. +* link:PROOF-NEEDS.adoc[`+docs/PROOF-NEEDS.adoc+`] — the detailed register and + its build receipts. +* link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`] + — the routing audit that produced R7, R8 and D9. diff --git a/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc b/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc new file mode 100644 index 00000000..b5061712 --- /dev/null +++ b/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc @@ -0,0 +1,992 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 ECHIDNA Project Contributors + += Re-recon: routing surfaces and the rule-engine source + +*Status*: current. Supersedes nothing; this is the first pass that classifies +*every* client-facing path in the tree rather than only the ones a given +consumer happens to use. *Date*: 2026-11-01. *Method*: static read of the +checkout at `916b769` — no build, see <>. + +This document answers four questions that were being asked of different +documents with different answers: + +. Is the VeriSimDB bridge a *persistence adapter* or a *mandatory VCL +communication path*? +. How does that reconcile with clients that call the echidnabot REST/GraphQL +surfaces directly, and with proof-burrower's `echidna prove` subprocess +contract? +. Which rule engine is live — the Logtalk module in `rules/`, or the Elixir +rules in Hypatia? +. Which paths in the tree are *active*, *compatibility-only*, *archival* or +*dead*? + +== 0. Verdicts + +*VeriSimDB is a persistence adapter, not a bus.* It is behind the non-default +`verisim` Cargo feature, it is written to fire-and-forget, and no prover is +reached through it. Nothing in the default build contacts it. There is exactly +one exception, and it is an accident rather than a design: enabling `verisim` +also switches `POST /api/prove` from "factory + backend" onto +`ProverDispatcher` (<>). Persistence should not be the switch that turns +the trust pipeline on. + +*VCL-UT is a query layer over VeriSim octads, not a transport ECHIDNA requires.* +In a default build `vcl_ut::cross_prover_search_names` is a stub returning +`Ok(vec![])` and `QueryExecutor` holds a `PhantomData`. A client that never +enables the feature never pays for it. + +*Clients do not need any of that.* echidnabot talks to `/api/*` (core server) +or `/graphql` (the separate `echidna-graphql` binary); proof-burrower looks for +`echidna prove --output json` and reads one JCS line off stdout. Those three +paths are independent and none of them routes through VeriSimDB. + +*The Logtalk material is dead and has been quarantined.* `rules/security_learning.lgt` +was the only `.lgt` file in the tree, referenced by nothing — not a test, not a +workflow, not a Justfile recipe, not the REUSE manifest's per-file headers. +Estate rule authoring is Elixir in Hypatia. The file now lives under +`archive/logtalk/` with a status header; `rules/` no longer exists. + +*Two routing defects were fixed en route* (container entrypoint and health +probes, <>), *one was documented and left open* (the 8081 port collision, +<>), *and one is a live correctness gap* (integrity checking never +configured, <>). + +== 0.1 Policy frame — what this document is allowed to claim + +Three normative sources bound this recon, all outside this repository. Each one +changed a conclusion, not just a footnote. + +[width="100%",cols="26%,20%,54%",options="header"] +|=== +|Source |What it governs |Effect here + +|`+hyperpolymath/standards+`: +`+3-practice/PROOF-POLICY.adoc+` +|Which verifier carries which obligation; the closed status vocabulary +(§5: `+proven+`, `+proven-with-postulates+`, `+tested-only+`, `+argued+`, +`+open+`). +|§2.2 states that ECHIDNA is "`the longer-term automation destination, not a +prerequisite for current independent checks`" — an external, normative reason +the VeriSim/VCL bridge *cannot* be a mandatory path. §3 puts +`+proofs//+` trees outside the policy entirely, which settles how this +recon classifies them. §5 supplies the only status tokens used in §7. + +|`+hyperpolymath/standards+`: +`+0-canon/PORT-REGISTRY.adoc+` +|"`Every project MUST use a unique port. No two projects share a port.`" +|Upgrades <> from untidiness to a policy violation, and is why the fix +is an *override* plus a corrected record rather than a shrug. + +|`+hyperpolymath/mathesis+`: +"`Estate-wide Mathematical Knowledge Architecture`" §5–§6 +|Verification grades (Grade 3 = type-checks in its home system, not translated; +Grade 2i/2ii = one/two independent translations; Grade 1 = machine-checked via +a verified checker API in headless CI). §6 forbids flattening assurance into a +percentage. +|Sets the ceiling on everything below. Nothing in this recon is above *Grade +3*, because nothing was translated or independently checked: it is a read of +one checkout. §6 is also why `+trust.confidence+` is discussed as +receipt-or-null rather than as a number (see link:../PROOF-STATUS.adoc[`+docs/PROOF-STATUS.adoc+`] +R7/R8). +|=== + +=== Claim ledger + +Every headline claim in this document, graded with the vocabulary it is asking +other people to use. This is deliberately unflattering: the honest row for most +of this work is `+argued+`. + +[width="100%",cols="42%,14%,44%",options="header"] +|=== +|Claim |Row |What would move it up + +|VeriSim is optional and non-blocking; no prover is reached through it +|`+proven+` (by construction) +|Already as strong as a static claim gets: the call sites are `+cfg`-gated and +the writes are `+tokio::spawn+`ed. A build-level test asserting zero network +calls with the feature off would make it mechanical. + +|VCL-UT degrades to a stub without the feature +|`+proven+` (by construction) +|Two compiled variants of the same signature exist in `+vcl_ut.rs+`; the +non-feature one returns `+Ok(vec![])+`. + +|echidnabot's and proof-burrower's client paths do not depend on VeriSim +|`+argued+` +|They are other repositories. Verify from their trees, not from this one's +records. + +|`+rules/security_learning.lgt+` was loaded by nothing +|`+argued+` (grep-exhaustive over this tree) +|Green state only if no external consumer exists. Its input path points at a +home directory, which makes one unlikely; it does not prove absence. + +|The container could not start +|`+proven+` (subcommand mismatch is decidable by reading `+Commands+`) +|Already decided: no `+serve+` variant, no alias. + +|Solver integrity is not enforced +|`+proven+` for "no caller attaches a checker"; `+argued+` for the security +consequence +|The first is a grep over the tree. The second needs a run with a checker +attached to observe the difference. + +|`+docs-site/+` is unbuilt +|`+argued+` +|No workflow in this tree builds it. A forge-side check would settle whether +some other system publishes it. +|=== + +=== What is therefore *not* claimed + +* No claim that any proof in this repository is `+proven+`. See + link:../PROOF-STATUS.adoc[`+docs/PROOF-STATUS.adoc+`], where every obligation + row is `+open+` or `+argued+`, including the Agda meta-checker. +* No Grade 2/1 claim anywhere. There is no translation leg and no receipt + pinned to a commit hash, which Mathesis §6 requires. +* No percentage. Where a number exists in this codebase + (`+verification/confidence.rs+`), it ranks backends against each other and is + recorded as `+tested-only+` for internal ranking, never as assurance. +* No interoperability claim between ECHIDNA and `epistemic-types`. The two + share a distinction, not a dependency — see §7. + +== 1. Routing inventory + +Four HTTP listeners are compiled into this repository. They are *not* one +service on four ports; they are four independent binaries with different route +tables, different handlers and different trust behaviour. + +[width="100%",cols="20%,18%,14%,48%",options="header"] +|=== +|Binary |Bind (default) |Route root |Notes + +|`+echidna server+` +|`+127.0.0.1:8081+` — `main.rs:165` +|`+/api/*+` +|The core server. 18 routes, documented from `+src/rust/server.rs:171+`. +Talks to the Julia ML sidecar (hardcoded `+http://127.0.0.1:8090+`, +`+server.rs:64+`) — as does `+echidna-rest+`; see the note below. This is the +only listener whose *behaviour* changes with the `+verisim+` feature. + +|`+echidna-rest+` (`+src/interfaces/rest+`) +|`+127.0.0.1:8000+` — `ECHIDNA_REST_ADDR`, `main.rs:142` +|`+/api/v1/*+`, `+/health+` +|Independent OpenAPI surface. Own session map, own `+ProofFactory+` calls. +Does *not* share `+echidna server+` state, routes or trust path. + +|`+echidna-graphql+` (`+src/interfaces/graphql+`) +|`+127.0.0.1:8081+` — `ECHIDNA_GRAPHQL_ADDR`, default unchanged, `main.rs` +|`+/+` (POST = GraphQL, GET = playground), `+/health+` +|Same default port as the core server. Env override added by <>. + +|`+echidna-grpc+` (`+src/interfaces/grpc+`) +|`+[::1]:50051+` — `main.rs:594` +|`+echidna.proto+` `+ProofService+` +|IPv6 loopback only. No REST equivalent. +|=== + +Non-HTTP client paths, which matter just as much: + +[width="100%",cols="26%,20%,54%",options="header"] +|=== +|Path |Entry point |Contract + +|CLI subprocess +|`+echidna prove --output json+` +|One JCS-canonical `+echidna.prove.result/1+` object on stdout, exit 0 iff +`+verified+`. Specified in link:../PROVE-RESULT-CONTRACT.adoc[`+docs/PROVE-RESULT-CONTRACT.adoc+`]. +No server, no socket, no VeriSimDB. + +|MCP stdio +|`+echidna-mcp+` (`+crates/echidna-mcp+`) +|Shells out to the `+echidna+` binary. Two of its three tools match the current +CLI; one does not (<>). + +|Cap'n Proto +|`+crates/echidna-wire+` +|Schema-only (`+common/prover/proof/gnn/verisim_er.capnp+`). No transport ships +yet; `+src/rust/server.rs+` still speaks JSON. +|=== + +One diagram correction: `+docs/ARCHITECTURE.adoc+` used to render a +"`REST / GraphQL (axum :8000)`" box. That port belongs to `+echidna-rest+`; the +core server is 8081 and serves no GraphQL at all. + +One smaller observation from the same pass, recorded rather than fixed. The +Julia ML sidecar URL appears four times: + +* `+server.rs:64+` — `+DEFAULT_ML_API_URL+`, which `+server.rs:71+` correctly + overrides from `+ECHIDNA_ML_API_URL+`; +* `+server.rs:1271+` — hardcoded again inside the suggest handler, so that one + path ignores the variable; +* `+src/interfaces/rest/main.rs:76+` and + `+src/interfaces/graphql/resolvers.rs:491+` — hardcoded, no environment read. + +`+docs/ENV-VARS.adoc+` describes `+ECHIDNA_ML_API_URL+` as the way to move the +sidecar. True for the core server's main path, false for three others. This is +config plumbing across four call sites in three binaries, so it is a change for +its own PR, not a drive-by. + +== 2. VeriSim: adapter, not a communication path + +=== 2.1 What the feature flag actually gates + +`+verisim+` is off in `+default = []+` (`+Cargo.toml:118+`). It enables +`+dep:ciborium+` (the CBOR encoder for the Semantic octad) and four call sites: + +[width="100%",cols="34%,22%,44%",options="header"] +|=== +|Call site |Direction |Behaviour when absent + +|`+dispatch.rs:725+` `+spawn_record_attempt+` +|write +|`+tokio::spawn+`ed `+record_proof_attempt+` after each dispatch exit. Errors +logged at `+warn+` and swallowed. *Cannot* fail a dispatch. + +|`+dispatch.rs:207+` `+VeriSimAdvisor+` +|read +|`+verify_proof_verisim_guided+` asks +`+mv_prover_success_by_class+` which prover to try. Any error → caller's +fallback prover. Advisory only. + +|`+vcl_ut.rs:349+` `+QueryExecutor+` +|read +|`+PhantomData+` stub; `+cross_prover_search_names+` returns `+Ok(vec![])+` +(`+vcl_ut.rs:951+`). + +|`+server.rs:104+` +|wiring +|`+with_verisim_writer(VERISIM_URL)+`; absent → dispatcher with no writer. +|=== + +Read that table as a whole: every one of the four is a *side effect on, or a +hint to*, a proof run. None of them carries a proof request, a proof result, or +a client command. + +=== 2.2 The one place the flag changes routing + +`+POST /api/prove+` is compiled twice (`+server.rs:423–445+`): + +* with `+verisim+`: `+app_state.dispatcher.verify_proof(...)+` — trust pipeline; +* without: `+ProverFactory::create+` then `+parse_string+` directly — no + dispatcher. + +So the presence of a *storage* feature decides whether an HTTP proof takes the +arbitration path. Two consequences worth stating plainly: + +. A deployment that wants the trust pipeline must enable a database + integration it may not have, and point it at a URL that will then be probed. +. A deployment that enables it for the learning loop silently changes its + verification routing. + +This is recorded as <>. It is not fixed here: the fix is a behaviour +change that deserves its own decision record, and any consumer that currently +relies on the flag to select the pipeline would be moved by surprise. + +=== 2.3 What never touches VeriSim + +`+echidna prove+` (human and `+--output json+`), `+/api/verify+`, +`+/api/verify_raw+`, `+echidna-rest+`, `+echidna-graphql+`, `+echidna-grpc+`, +`+echidna-mcp+`. `/api/search` and `+echidna search+` *do* consult VCL-UT, but +only to append cross-prover results, and only in a `+verisim+` build — the +stub swallows the call otherwise (`+server.rs:1043+`, `+main.rs:924+`). + +=== 2.4 Drift found in the VeriSim surface + +* `+.machine_readable/integrations/verisim.a2ml+` declares `+format = "hexad"+` + and a `+verisimdb-data/feeds/+` destination. The implemented and formally + specified model is an *octad* (8 modalities, + link:../architecture/VERISIM-ER-SCHEMA.adoc[`+VERISIM-ER-SCHEMA.adoc+`]). + "Hexad" is not a modality count that exists anywhere in the tree. +* link:../design/ECHIDNA-VERISIM-TRIANGULATION-2026-04-17.adoc[`+ECHIDNA-VERISIM-TRIANGULATION-2026-04-17.adoc+`] + describes the *workflow* for absorbing provers and predates the entity model. + `+VERISIM-ER-SCHEMA.adoc+` says it "replaces the aspirational text" there. + Classified archival below; not edited. +* `+docs/ECOSYSTEM-INTEGRATION.adoc+` still lists VeriSimDB as "🔄 Manual" with + a `+ingest-scan.sh+` recipe. The *code* path (octad writer + `proof_attempts` + table) is more advanced than that recipe; the recipe is a separate, + scanner-driven ingestion route that predates it. Both are real; neither is + the other. +* `doc/…`-level claim "HTTP push deferred" in + `+.machine_readable/descriptiles/ECOSYSTEM.a2ml:29+` is correct for the + corpus adapter and incorrect for `+dispatch.rs+`, which pushes today. The two + paths emit different payloads (octads vs corpus records) and both are live. + +== 3. Client reconciliation + +=== 3.1 echidnabot, calling REST or GraphQL directly + +Two independent surfaces, and the doc has to name which: + +* *Core server* (`+/api/prove+`, `+/api/verify+`, `+/api/provers+`, + `+/api/health+`) — port 8081 by default, route table in + `+src/rust/server.rs+`. This is what published examples and + `+server-boot-gate.yml+` exercise. +* *`+echidna-rest+`* (`+/api/v1/proofs+`, `+/api/v1/provers+`, …) — port 8000, + OpenAPI + ReDoc. Different paths, different session store, no `/api/prove`. +* *`+echidna-graphql+`* — `+POST /+` on 8081. Queries are `+provers+`, + `+proofState(id:)+`, `+listProofs+`, `+suggestTacticsByProofId+`, + `+proverStatus+`, `+health+`; mutations `+submitProof+`, `+applyTactic+`, + `+cancelProof+`, `+verifyProof+`, `+suggestTactics+`. + +A client that hard-codes 8081 for both REST and GraphQL is relying on two +binaries not being co-resident. See <>. + +*Trust honesty for REST clients*: `/api/verify` never enters the dispatcher +(`+server.rs:519+`), and `/api/verify_raw+` execs the solver binary directly by +design. Only `/api/prove` reaches arbitration, and only in a `+verisim+` build. +A REST client cannot tell, from the response, which of these it got: +`+ProveResponse+` is `+{success, goals, message}+` and `+VerifyResponse+` is +`+{valid, outcome, goals_remaining, tactics_used, mode, smt_status}+`. Neither +carries a schema identifier or a trust level, unlike the prove contract. That +asymmetry is the strongest argument for pointing new clients at +`+echidna prove --output json+` rather than HTTP. + +=== 3.2 proof-burrower, the `+echidna prove+` subprocess contract + +This is the best-specified client path in the tree and it needs no network at +all: + +* one line, JCS-canonical, on stdout; logs and `+--diagnose+` on stderr; +* `+status+` ∈ `+verified | failed | error | timeout | unknown+`, derived from + `+ProverOutcome+` via a total mapping; +* `+trust.confidence+` is `+null+` on this path *always* — a single-backend + check verifies no certificate, so there is no receipt. This is the + receipt/warrant distinction applied honestly, and it matches where + `+epistemic-types+` has landed: ECHIDNA copies the distinction as a pattern, + it does not depend on the Agda library and does not claim interop with it; +* exit code 0 iff `+verified+`; +* capability detection by probing `+echidna prove --help+` for `+--output+`. + +Feature-detection matters: the contract is *additive* per its own versioning +rules, so older binaries answering without `+--output+` are still usable, and +new clients must ignore unknown fields. + +=== 3.3 `+echidna-mcp+` — the client that drifted + +`+crates/echidna-mcp/src/main.rs+` invokes: + +* `+echidna prove --format json+`. `+--format+` *is* a global flag + (`+main.rs:43+`, `+global = true+`) so this parses, but it selects the CLI + formatter (`+{"level":"success",…}+`), not the prove contract. The tool then + sniffs for `+"level": "success"+`. It works and it is the *legacy* shape: a + second, undocumented JSON dialect for the same operation. +* `+echidna list-provers --format json+` — parses; no contract at all. +* `+echidna check-prover +` — *no such subcommand exists* + (`+Commands+` in `+main.rs:57–450+` has no `+CheckProver+`). The tool fails + with a clap usage error every time. + +Recorded as <>. Not fixed here: the correct repair is a rewrite onto +`+--output json+` plus a `+check-prover+` subcommand or a `/api/provers` probe, +which is a feature decision, not a rename. + +=== 3.4 What no client can get today + +* A partial-progress field. The contract has no "remaining subgoals"; a swarm + that wants to score partial attempts must parse prover output. +* A receipt-backed confidence via HTTP. `+/api/prove+` has no `+trust+` object. +* A single surface that is both OpenAPI-described and trust-pipeline-backed. + +None of these are bugs in the contract. They are the current *scope*, and the +useful thing a recon can do is stop other documents implying otherwise. + +== 4. Logtalk: what was left and why it is dead + +=== 4.1 What was there + +`+rules/security_learning.lgt+` — 234 lines, two Logtalk objects +(`+security_learning+`, `+cicd_integration+`), dated 2025-12-29. It encodes 20 +alert categories from cicd-hyper-a's `+ERROR-CATALOG.scm+`, a severity +classifier, a fix-suggestion table, seven prevention workflows, and a +`+assertz+`-based "learning" loop that re-derives rules from its own training +data. + +=== 4.2 Why it is dead + +* *Nothing loads it.* `+grep -rn '\.lgt' --exclude-dir=.git .+` returns the file + and one self-reference inside it. No test, workflow, Justfile recipe, A2ML + manifest or Cargo build step names it. No `+logtalk+`/`+swipl+` binary is + referenced anywhere in CI. +* *Its inputs are on someone's disk.* `+cicd_repo_path('~/repos/cicd-hyper-a/.audittraining')+` + — a home-directory path with a literal `+~+`, in an object whose job is to + open it. Not portable, not parameterised, not a contract. +* *Its learning loop cannot learn.* `+load_training_data/1+` calls + `+read(Stream, Term)+` on a Prolog stream but the file it is pointed at is a + Scheme source (`+ERROR-CATALOG.scm+`). Every term fails + `+process_training_term/1+` and hits the catch-all `+process_training_term(_)+`. + `+learn_rules/0+` then asserts whatever the static tables already said. The + output is a transcript of the input constants. +* *The estate moved.* `+docs/ECOSYSTEM-INTEGRATION.adoc:117+` described Hypatia + as auto-generating Logtalk rules after 5+ observations. Hypatia's rule + engine is Elixir; the estate's rule-as-data path is Elixir rule modules, not + Logtalk objects. An ECHIDNA-side Logtalk module was never a consumer of that + output, even when the prose was accurate. +* *The one live rule surface in this repo is `+.hypatia-baseline.json+` + + `+.hypatia-ignore+`* — i.e. ECHIDNA is a Hypatia *target*, not a rule + producer. + +=== 4.3 Disposition + +Moved, not deleted, and marked: + +[width="100%",cols="34%,66%",options="header"] +|=== +|Before |After + +|`+rules/security_learning.lgt+` +|`+archive/logtalk/security_learning.lgt+` — body unchanged (so the record is +byte-recoverable), preceded by a `+DEAD SOURCE+` block naming the date, the +reason and the successor. + +|_(no index)_ +|`+archive/logtalk/README.adoc+` — what it was, why it is here, what would have +to be true to revive it. + +|`+.reuse/dep5+` `+Files: rules/*+` +|`+Files: archive/*+` — the same MPL-2.0 bulk stanza, retargeted. + +|`+.machine_readable/root-allow.txt+` +|`+archive/+` added, because it is a root entry outside the template — the file +exists precisely to record those. +|=== + +`+rules/+` is gone rather than emptied, so that the tree cannot imply "ECHIDNA +has rules in Logtalk" by the existence of the directory. + +=== 4.4 Prose also retired + +* `+docs/ECOSYSTEM-INTEGRATION.adoc+` — the Logtalk-generation sentence now + states Elixir and links here. +* `+echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml+` — + `+report-format = "logtalk"+` retired to a comment. That key told a scanner + consumer to expect a format nothing produces. + +== 5. Path classification + +Four classes, used strictly: + +* *active* — built, called, or deployed by something in this tree today; +* *compatibility-only* — works, but exists to serve a consumer or a shape that + a newer path supersedes; must not gain new capability; +* *archival* — retained as a record; explicitly not a build or usage + instruction; +* *dead* — nothing loads it and nothing can. + +[width="100%",cols="38%,14%,48%",options="header"] +|=== +|Path |Class |Basis + +|`+src/rust/main.rs+` (`+server+`, `+prove+`, `+verify+`, `+search+`, `+corpus+`, …) +|active +|The binary. Booted by `+server-boot-gate.yml+`, exercised by `+QUICKSTART-USER.adoc+`. + +|`+src/rust/server.rs+` +|active +|18 routes; the published API (`+site/docs/api/core.adoc+`). + +|`+src/rust/dispatch.rs+` +|active +|`+ProverDispatcher+`. Caveat <> and <>. + +|`+src/rust/vcl_ut.rs+` +|active (conditional) +|Real executor under `+verisim+`; deliberate stub without it. Both compiled and +tested. + +|`+src/rust/verisim_bridge.rs+` +|active (conditional) +|Octad writer/reader. Non-default feature; weekly `+s4-loop.yml+` runs it. + +|`+crates/echidna-core+` +|active +|Published shared contract types (`+prove_result+`, `+prover_kind+`) for +external consumers. + +|`+crates/echidna-wire+` +|active, unbuilt in CI +|Schemas only; `+STATE.a2ml+` records `cargo build` failing on a missing `+capnp+` +binary — pre-existing, unrelated to routing. + +|`+crates/echidna-mcp+` +|compatibility-only +|`+prove+` and `+list_provers+` work via the legacy formatter dialect; +`+check_prover+` is dead (<>). + +|`+src/interfaces/rest+`, `+graphql+`, `+grpc+` +|active +|Separate binaries, in the workspace, documented in `+site/docs/api/+`. + +|`+src/interfaces/*/ffi_wrapper.rs+` +|compatibility-only +|C shim against `+libechidna_ffi.so+`; every binary falls back to direct Rust +calls when it is absent (`+ffi_integration_test.rs+`). Self-described +scaffolding. + +|`+echidna prove --output json+` (`+prove_contract.rs+`, +`+echidna-core/prove_result.rs+`) +|active +|The supported machine contract; golden-JCS tests; consumed by proof-burrower. + +|`+--format json+` global CLI output +|compatibility-only +|Older machine dialect (`+{"level":…}+`), still used by MCP. Superseded by the +prove contract; no new consumer should adopt it. + +|`+/api/prover+`, `+/api/verify+` response shapes +|compatibility-only +|Untagged JSON; no schema id, no trust block. Kept for existing clients. + +|`+container/entrypoint.sh+` +|was dead → fixed +|Called `+echidna serve+`; the subcommand is `+server+`. Every container start +failed at `+exec+`. + +|`+container/compose.toml+` healthcheck +|was dead → fixed +|Probed `+/health+`; the core server only serves `+/api/health+`. + +|`+container/vordr.toml+` readiness probe +|was dead → fixed +|Probed `+/ready+`, which no binary in this tree has ever served. + +|`+container/manifest.toml+`, `+Containerfile+` +|active +|`+EXPOSE 8081+` matches the default; `+APP_PORT+` is consumed by the +entrypoint, not the binary. + +|`+rules/security_learning.lgt+` +|dead → quarantined +|§4.2. + +|`+archive/logtalk/+` +|archival +|Quarantine, not a build input. Indexed by `+archive/logtalk/README.adoc+`. + +|`+proofs//+` trees (16 directories) and `+training_data/+` +|archival *(out of policy)* +|Fixtures, not obligations: `+PROOF-POLICY.adoc+` §3 names these explicitly — +"`echidna's `+proofs//+` trees and similar fixtures are test material +for a multi-prover system and are outside this policy`". They exercise parsers +and backends. No status row, and none owed — see +link:../PROOF-STATUS.adoc[`+docs/PROOF-STATUS.adoc+`] "Not obligations". + +|`+docs/ECOSYSTEM-INTEGRATION.adoc+` +|archival (corrected) +|2026-02-12 point-in-time status; kept as a record, Logtalk claim retired. + +|`+docs/design/ECHIDNA-VERISIM-TRIANGULATION-2026-04-17.adoc+` +|archival +|Superseded by `+VERISIM-ER-SCHEMA.adoc+` in its own words. + +|`+docs/architecture/VERISIM-ER-SCHEMA.adoc+`, `+crates/echidna-wire/schemas/verisim_er.capnp+` +|active +|Canonical entity model + wire schema. + +|`+.machine_readable/integrations/verisim.a2ml+` +|active but drifted +|`+hexad+` is not a model that exists (<>). Still the declared feed +manifest. + +|`+docs-site/+` (content + Eclexia TEA app) +|dead +|No workflow builds or deploys it: `+pages.yml+` publishes `+site/+` via Ddraig. +Its API pages claim `+/graphql+`, `+/playground+`, "30 provers" and +`+localhost:8000/api/v1+` — none of which matches the code. Not edited: editing +a dead publisher only makes the corpse tidier. Delete or rebuild as a decision. + +|`+site/+` + `+site/docs/api/*.adoc+` +|active +|Deployed by `+pages.yml+`; content matches the source as of this read. + +|`+docs-site/.well-known/aibdp.json+`, `+docs-site/config/policy.yml+` +|archival +|Consumed only if `+docs-site+` is ever served. + +|`+docs/reports/audit/+`, `+docs/releases/*+`, `+docs/handover/*+` +|archival +|Dated records. `+RELEASE_NOTES_v1.3.adoc+` still shows `+--enable-cors+`, which +the CLI spells `+--cors+`; left alone deliberately. +|=== + +== 6. Defects found + +IDs here are local to this report (`+RD`*n*), because link:../DEBT.adoc[`+docs/DEBT.adoc+`] +already uses `+D`*n* for its documentation-debt entries and `+C`*n* for code. +The ones that outlive this report carry a DEBT number: + +[width="100%",cols="12%,22%,66%",options="header"] +|=== +|Here |In `+DEBT.adoc+` |Form + +|RD1 |D10 |open +|RD2 |D8 |partly fixed (override + corrected record); default port open +|RD3 |— |fixed, recorded in DEBT's "`Fixed en route`" list +|RD4 |D11 |open +|RD5 |D9 |docs fixed, enforcement decision open +|RD6 |— |open, small; no DEBT number because there is nothing to decide +|RD7 |D12 |open +|RD8 |— |open, small; no DEBT number because there is nothing to decide +|=== + +[[rd1]] +=== RD1. `+verisim+` is a routing switch as well as a storage switch +*P1 — structural, open.* + +`+server.rs:423+` compiles `/api/prove` two ways; whether an HTTP proof enters +`+ProverDispatcher+` depends on a database feature. No caller-visible field +distinguishes the two behaviours. + +*Fix direction* (not applied): build the dispatcher unconditionally and make +`+with_verisim_writer+` purely additive — the writer already cannot fail a +dispatch, so nothing needs the compile-time branch. Requires a decision record +because it changes what a default build returns. + +*Reproduce:* +[source,bash] +---- +sed -n '420,450p' src/rust/server.rs +grep -n 'cfg(feature = "verisim")' src/rust/server.rs +---- + +[[rd2]] +=== RD2. Core server and GraphQL interface both default to 8081 +*P1 — documented, open.* + +`+main.rs:165+` (`+server --port+` default 8081) and +`+src/interfaces/graphql/main.rs:39+` (`+127.0.0.1:8081+`, hard-coded). Whichever +starts second cannot bind. `+docs/QUICKSTART-USER.adoc+` claimed the three +interfaces sit "on dedicated ports (8081 / 50051 / 8000)" — false for the first +two pairings. + +This is a policy matter, not a preference: +`+0-canon/PORT-REGISTRY.adoc+` in hyperpolymath/standards says "`Every project +MUST use a unique port. No two projects share a port.`" The registry is +prose-only today ("`no gate reads this registry`"), which is why the collision +survived a release. + +Done here: `+ECHIDNA_GRAPHQL_ADDR+` override (default unchanged, so no client +breaks), and explicit collision warnings in `+site/docs/index.adoc+`, +`+site/docs/api/graphql.adoc+` and `+QUICKSTART-USER.adoc+`, plus +`+core-server = 8081+` in the `+[interfaces]+` block of `+META.a2ml+` so the +machine record cannot omit it. + +Not done: moving the default. Anything pinned to 8081 keeps working; a +deployment that wants both sets the variable. + +*Reproduce:* +[source,bash] +---- +grep -n 'default_value = "8081"' src/rust/main.rs +grep -n '127.0.0.1:8081' src/interfaces/graphql/main.rs +---- + +[[rd3]] +=== RD3. Container entrypoint and health probes targeted strings that do not exist +*P2 — fixed.* + +`+entrypoint.sh+` ran `+echidna serve+` (subcommand is `+server+`, no alias); +`+compose.toml+` probed `+/health+` and `+vordr.toml+` probed `+/ready+`, neither +of which the core server serves. Net effect: the published container could not +start, and would have been unhealthy if it had. + +Three-line fix in each file; `+/api/health+` is the real route +(`+server.rs:173+`). + +*Adjacent, found and deliberately not fixed.* `+container/compose.toml+` is not +valid TOML 1.0: its `+environment = { … }+` inline tables span multiple lines, +which TOML forbids. `+selur-compose+` accepts the dialect, but the file's own +header offers `+podman compose --file compose.toml+` as the fallback when selur +is absent, and a strict parser rejects the file before it looks at the routes +(verified: Python's `+tomllib+` fails at line 19, column 16, on both the +pre-and-post-change file). Rewriting the tables to `+[services.echidna.environment]+` +would fix the fallback but changes what selur reads, so it needs whoever owns +the selur format to confirm rather than a guess. + +[[rd4]] +=== RD4. `+echidna-mcp+` calls a subcommand that does not exist +*P2 — open.* + +`+cmd.arg("check-prover")+` (`+main.rs:176+`). Fix requires adding the +subcommand or probing `/api/provers`; both are feature decisions. Its `prove` +tool additionally uses the legacy `+--format json+` dialect instead of +`+--output json+`. + +[[rd5]] +=== RD5. Solver integrity checking is never configured +*P1 — open, and the largest gap this pass found.* + +`+docs/ARCHITECTURE.adoc+` promises step 1 of the trust pipeline is +`+integrity/+`: "solver binary SHAKE3-512 + BLAKE3 against +`+config/solver-manifest.toml+`". In `+dispatch.rs:632–645+` the check runs only +`+if let Some(ref checker) = self.integrity_checker+`, else +`+solver_integrity_ok = true+`. `+with_integrity_checker+` has *no caller +anywhere in the tree* (`+grep -rn with_integrity_checker src/ crates/ tests/+` +returns the definition only). And `+config/solver-manifest.toml+` still carries +`+shake3_512 = "PLACEHOLDER_COMPUTE_ON_FIRST_RUN"+` for every solver, so +configuring a checker today would report `+Uninitialized+` — which the code +treats as passing. + +So: integrity is *assumed* ok on every path, and the manifest could not prove +otherwise if it were consulted. Report as a gap; wiring it is a decision (it +introduces a failure mode where a proof is refused for a hash mismatch). + +*Reproduce:* +[source,bash] +---- +grep -rn 'with_integrity_checker' src/ crates/ tests/ # definition only +grep -n 'PLACEHOLDER' config/solver-manifest.toml +---- + +[[rd6]] +=== RD6. VeriSim feed manifest says `+hexad+` +*P2 — open.* + +`+.machine_readable/integrations/verisim.a2ml+`: `+format = "hexad"+`. The model +is an octad. Either the manifest is stale or the feed is a different shape than +the octad writer; the document does not say which, and nothing reconciles them. + +[[rd7]] +=== RD7. `+docs-site/+` publishes stale API claims +*P2 — open.* + +Not deployed (see §5), but it is linkable and states `/graphql` and +`/playground` endpoints (the binary serves `/`), "30 supported prover backends" +(canonical count is in `+docs/PROVER_COUNT.adoc+`), and a `+localhost:8000/api/v1+` +base. Delete or rebuild; both are cheap, drift is not. + +[[rd8]] +=== RD8. `+verify_parallel+` ignores the configured dispatcher +*P2 — open.* + +`+server.rs:619+` constructs a fresh `+ProverDispatcher::new()+` instead of using +`+app_state.dispatcher+`. With `+verisim+` on, parallel verifies therefore +record nothing in `+proof_attempts+` while serial `/api/prove` calls do — a +silent second inconsistency in the same learning loop that <> describes. + +[[limits]] +== 7. Limits of this pass + +No build was run and none could be: this checkout has no cargo toolchain, no +`+agda+`, no Creusot, and no network access to a crate registry. Everything +above is a static read of source and configuration at `916b769`. Where a claim +depends on runtime behaviour it says so and gives the command that would settle +it. + +By the ledger vocabulary of `+PROOF-POLICY.adoc+` §5, that makes the *whole +document* `+argued+` — a transcription no kernel, compiler or test runner +checked. The claim ledger in §0.1 grades the individual claims; none of them is +above `+proven+`, and the ones that reach `+proven+` (by construction) only do +so for facts decidable by reading, such as "no `+serve+` variant exists" or +"this branch is `+cfg`-gated". By the Mathesis grades this is at best *Grade 3* +in shape — locally consistent, untranslated, independently unchecked — and the +Agda legs are *not* even locally checked here, since no Agda is installed. + +Claims about other repositories — proof-burrower's parser tolerance, +echidnabot's client wiring, Hypatia's generated-rule format — are taken from +this repository's own records and are *not* independently verified here. The +`+issues/+` drafts already mark such claims *[unverified]*; this document keeps +that discipline. + +Scope note: `+epistemic-types@5422cd5+` (22 strict-safe Agda modules, Kripke +semantics, receipt/warrant split) is *context* for what ECHIDNA may claim, not a +dependency. `+docs/PROVE-RESULT-CONTRACT.adoc+` and +`+crates/echidna-core/src/prove_result.rs+` both describe the relationship as +"copies the distinction as a pattern; does not depend on the Agda library" — +which is the accurate, scoped statement, and this recon found no place +overclaiming universal interoperability. + +== 8. Reproduction + +[source,bash] +---- +# every HTTP listener and its default bind +grep -n 'TcpListener::bind\|let addr =' src/rust/server.rs src/interfaces/*/main.rs +grep -n 'default_value = "8081"' src/rust/main.rs + +# the feature flag that is off by default +sed -n '115,132p' Cargo.toml +grep -rn 'cfg(feature = "verisim")' src/rust/ | wc -l + +# nothing loads Logtalk +grep -rn '\.lgt' --exclude-dir=.git . # archived file + its own self-reference +grep -rni 'logtalk' --exclude-dir=.git . # prose only, after this change + +# the prove contract vs the legacy formatter +grep -n 'ProveOutput\|--output' src/rust/main.rs | head +grep -n '"level"' src/rust/output.rs | head + +# integrity checker wiring +grep -rn 'with_integrity_checker' src/ crates/ tests/ # definition only +---- + +== 9. Follow-ups, in the order they are worth doing + +. <> — decide whether solver integrity is enforced or documented as not + enforced. The current text said it is enforced; that is the one item here + that could mislead someone into relying on an assumption. +. **DONE** — `+docs/PROOF-STATUS.adoc+` R4 re-type-checked with the canonical §2.1 + invocation. It passes; R4 is now `+proven+`, with the + `+UnsupportedIndexedMatch+` limit recorded rather than papered over. See + <>. +. `+docs/PROOF-STATUS.adoc+` R2 — cite a green `+idris2-abi-ci.yml+` run and its + commit for the ABI row. +. <> — decouple the trust pipeline from the `+verisim+` feature. +. <> — delete `+docs-site/+` or regenerate it from `+site/docs/+`. +. <> — move the GraphQL default off 8081 once a release note can carry + the change. +. <> — repair or remove `+echidna-mcp+'s `+check_prover+`. +. <>, <> — small, unambiguous, no decision needed. + +=== Not follow-ups + +`+proofs/+` trees being unverified, and every `+trust_level+` in the tree +being disconnected from a receipt. The first is policy (`+PROOF-POLICY+` §3: +corpora are not obligations); the second is recorded scope +(`+docs/PROOF-STATUS.adoc+` R7/R8). Chasing either as debt would be a category +error, which is why they are named here. + +[[proof-updating]] +== 10. Proof updating, 2026-11-01 + +The follow-up at §9.2 was executed, and the proof corpus was extended rather +than only re-checked. Receipts are in `+docs/PROOF-STATUS.adoc+` (R4, R9, R10, +R11) and the narrative in `+docs/PROOF-NEEDS.adoc+`; this section records what +the *audit* position now is. + +=== 10.1 The receipt that matters + +[source,bash] +---- +cd meta-checker +agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda +# exit 0, 98 s cold, 9 modules + 214 stdlib modules +---- + +Agda 2.7.0.1, `+agda-stdlib+` 2.1, commit `+916b769+`. Zero postulates — and now +excluded by `+--safe+` rather than by a `+grep+`, which is the difference between +`+argued+` and `+proven+` in §5 of the policy. + +**The limit, stated as a limit.** 53 warnings, all +`+-W[no]UnsupportedIndexedMatch+`, at `+TrustLevel+` 77/88/235, +`+AxiomSafety+` 119 and `+ClampTrust+` 98. Those functions do not *compute* +under Cubical transports. The corpus is `+--cubical-compatible+`, **not** +`+--cubical+` — `+PROOF-POLICY+` §2.1 draws exactly that distinction, and it is +why the flag stays and the warning is reported instead of suppressed. + +The obvious question — why not use `+--cubical+` and be done with it — was +tested rather than argued: `+agda --cubical --safe src/Echidna/MetaChecker.agda+` +fails **inside stdlib 2.1** (`+Data/List/Membership/Setoid/Properties.agda+`, +unsolved metas) before reaching any ECHIDNA module. The stdlib is written to be +`+--cubical-compatible+` and ships `+--warning=noUnsupportedIndexedMatch+` for +itself. So the flag chosen is the strongest the dependency set supports, and the +warning is a limit to record, not a weakness to hide. + +One more result worth having: the out-of-policy `+proofs/agda/+` tree — the one +R6 cites — is **13/13 green** under the same flags. That does not make it +in-policy, but it does change R6's remedy from "downgrade the claim" to "move the +file". + +=== 10.2 A module that had never been compiled + +`+meta-checker/src/Echidna/AxiomTrackerCompleteness.agda+` was in the tree, and +in the "30+ properties" summary, but nothing imported it and no workflow named +it — so it had never been type-checked by any gate. Compiling it showed a syntax +error (a hand-rolled `+_⊎_+` declared in a `+where+` clause on a type signature) +and two proofs that assumed `+worst-policy Clean b+` reduces, when +`+worst-policy+` splits on its first argument and the term is stuck. + +This is the same class of finding as the routing audit's: an artefact whose +existence was being reported as evidence. Two fixes and it is now in the +closure, and `+agda-meta-checker.yml+` gained a step that fails if any module +under `+src/Echidna/+` produces no interface — the orphan cannot recur silently. + +=== 10.3 Obligations that had prose but no proof + +Three new modules, all inside the receipt above: + +* `+Integrity.agda+` — **E11**, previously an informal note. It proves the + invariant the Rust *violates*: `+dispatch.rs+` 642/848/998 and + `+solver_integrity.rs+` 359 accept `+Uninitialized+` as passing. So R9 is + `+proven+` on the specification and the implementation leg stays open, keyed to + DEBT D9. This is the useful outcome: the divergence is now checked on one side + instead of read on both. +* `+ClampTrust.agda+` — the four `+ClampTrustBounds.idr+` obligations plus + idempotence, in a corpus that is actually run. It does not validate the Idris2 + file, which remains out of policy (§3) with no recorded run. +* `+Receipt.agda+` — the receipt/warrant distinction carried over from + `+epistemic-types@5422cd5+` as a two-world Kripke countermodel: a receipt is + factive, a warrant is not, and the same warrant never licences the stronger + token. Semantic rewrite, not a port — per §2.2 the source project keeps + ownership of its own theorems, and this module proves only what ECHIDNA needs. + It is what turns §5's "`+argued+` is not `+proven+`" from a style rule into a + theorem. + +=== 10.4 What the proof grade did and did not move + +*Home-system leg:* R4, R9, R10, R11 are `+proven+`. *Independent legs:* R5 +(Rocq + Lean) is untouched and `+open+`. So the corpus is still **Mathesis Grade +3** — locally checked, untranslated — and this session did not move it, because +nothing that establishes Grade 2i/2ii was performed. What moved is the policy +vocabulary, not the grade. + +=== 10.5 Toolchains that could not be installed + +No Rust, Creusot, Lean 4, Idris 2, Rocq, Z3 or cvc5 leg ran. The environment +reached only GitHub, PyPI and npm; GitHub *release assets* — the sole source of +those tarballs — were blocked, and `+scripts/install-proof-toolchains.sh+` +requires root, apt and the same host. Agda itself was only installable because a +PyPI wheel exists, and even that needed the `+data/lib+` primitives lifted from +the 2.7.0.1 tag to work. + +Per `+PROOF-POLICY+` §6, R1, R2, R3, R5 and every Stage-8a Idris2 obligation are +reported as not run, not as passing. The recon's §7 limits stand unchanged. + +=== 10.6 Version choice, flagged rather than assumed + +"Latest stable" for this corpus is Agda 2.8.0.2 with stdlib 2.4. Neither is +reachable here, and §2.1 says to pin versions per commit — so the receipt is +taken at 2.7.0.1 / 2.1, which is also what `+agda-meta-checker.yml+` pins. A +future bump is a deliberate act with a new receipt, not a side effect of +"update to latest". + +=== 10.7 The rest of the estate, checked rather than assumed + +The session was also asked about proofs elsewhere in the estate. What is +actually reachable from this environment: + +[width="100%",cols="24%,16%,60%",options="header"] +|=== +|Repository |Reachable |What was found + +|`+hyperpolymath/epistemic-types+` +|yes +|Cloned at `+5422cd5+` — the commit named in the brief. **22 `.agda` files, all 22 +in the checked closure, `+agda --safe --without-K --cubical-compatible+` exit 0, +0 postulates, 69 `+UnsupportedIndexedMatch+` warnings.** The brief's "22 +strict-safe modules" is exactly right, and the figure is now a receipt rather +than a recollection. Nothing was edited: §2.2 keeps proof ownership with the +source project, and this session cannot push outside its own branch anyway. What +ECHIDNA took is the receipt/warrant distinction, re-proved as R11. + +|`+hyperpolymath/the-metadatastician+` +|yes +|Exists (pushed 2026-10-13, language: Shell) with **zero proof artefacts** — no +`+.agda+`, `+.idr+`, `+.lean+`, `+.v+` or `+.thy+` anywhere in the tree. There is +no proof updating to do there; whatever is outstanding is not a proof problem. + +|`+hyperpolymath/stuff-of-awesome+` +|**no** +|404, and a user-scoped search returns 0 results. Not visible to this token: +either renamed, private, or never existed under this owner. + +|`+hyperpolymath/catachresis-colloquium+` +|**no** +|404, 0 results in a user-scoped search and 0 under the `+metadatastician+` org. +Same caveat: private or absent. +|=== + +A 404 is not proof of absence, so the last two rows are recorded as *not +visible from here* rather than as *does not exist*. If they are private, the +work needs a token with access; if they were renamed, the new names are needed. +Neither can be discharged by guessing. diff --git a/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml b/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml index f8f845f9..9797c75d 100644 --- a/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml +++ b/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml @@ -9,7 +9,12 @@ last-updated = "2026-04-11" [hypatia-config] scan-enabled = true scan-depth = "standard" # quick | standard | deep -report-format = "logtalk" +# report-format retired 2026-11-01. It read "logtalk", which no scanner in the +# estate emits: Hypatia's rule engine is Elixir, and this project is a scan +# target, not a rules producer. Left unset so the consumer applies its own +# default rather than being told to expect a format nothing produces. +# See docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc §4.4. +# report-format = "logtalk" [symbolic-rules] # Custom symbolic rules for this project diff --git a/meta-checker/src/Echidna/AxiomSafety.agda b/meta-checker/src/Echidna/AxiomSafety.agda index 153c0c73..7f084590 100644 --- a/meta-checker/src/Echidna/AxiomSafety.agda +++ b/meta-checker/src/Echidna/AxiomSafety.agda @@ -154,6 +154,37 @@ worst-ub-left IncompleteProof IncompleteProof = inc≤i worst-ub-left IncompleteProof Rejected = inc≤r worst-ub-left Rejected _ = rej≤r +-- THEOREM: Clean is a left unit for worst-case composition. +-- (`worst-policy` splits on its first argument, so this needs the +-- second argument enumerated even though the equation is "obvious".) +clean-left-neutral : ∀ (b : AxiomPolicy) → worst-policy Clean b ≡ b +clean-left-neutral Clean = refl +clean-left-neutral ClassicalAxioms = refl +clean-left-neutral IncompleteProof = refl +clean-left-neutral Rejected = refl + +-- THEOREM: worst-policy is an upper bound in its SECOND argument. +-- Needed to rewrite `fold-worst ps ≤ₚ worst-policy p (fold-worst ps)` +-- when the first argument is neutral: `worst-policy` splits on its first +-- argument, so the result is stuck until the second is enumerated. +worst-ub-right : ∀ (a b : AxiomPolicy) → b ≤ₚ worst-policy a b +worst-ub-right Clean Clean = clean≤ +worst-ub-right Clean ClassicalAxioms = class≤c +worst-ub-right Clean IncompleteProof = inc≤i +worst-ub-right Clean Rejected = rej≤r +worst-ub-right ClassicalAxioms Clean = clean≤ +worst-ub-right ClassicalAxioms ClassicalAxioms = class≤c +worst-ub-right ClassicalAxioms IncompleteProof = inc≤i +worst-ub-right ClassicalAxioms Rejected = rej≤r +worst-ub-right IncompleteProof Clean = clean≤ +worst-ub-right IncompleteProof ClassicalAxioms = class≤i +worst-ub-right IncompleteProof IncompleteProof = inc≤i +worst-ub-right IncompleteProof Rejected = rej≤r +worst-ub-right Rejected Clean = clean≤ +worst-ub-right Rejected ClassicalAxioms = class≤r +worst-ub-right Rejected IncompleteProof = inc≤r +worst-ub-right Rejected Rejected = rej≤r + -- THEOREM: worst-policy is commutative worst-comm : ∀ (a b : AxiomPolicy) → worst-policy a b ≡ worst-policy b a worst-comm Clean Clean = refl diff --git a/meta-checker/src/Echidna/AxiomTrackerCompleteness.agda b/meta-checker/src/Echidna/AxiomTrackerCompleteness.agda index 1191018c..624b0a7f 100644 --- a/meta-checker/src/Echidna/AxiomTrackerCompleteness.agda +++ b/meta-checker/src/Echidna/AxiomTrackerCompleteness.agda @@ -22,6 +22,7 @@ module Echidna.AxiomTrackerCompleteness where open import Data.Nat using (ℕ; zero; suc; _+_) open import Data.List using (List; []; _∷_; length; map; _++_) open import Data.Product using (_×_; _,_) +open import Data.Sum using (_⊎_; inj₁; inj₂) open import Relation.Binary.PropositionalEquality using (_≡_; refl; sym; trans; cong) open import Echidna.AxiomSafety @@ -78,7 +79,7 @@ scan-entry-length (mk-entry p (d ∷ ds)) = fold-worst-++ : ∀ (xs ys : List AxiomPolicy) → fold-worst (xs ++ ys) ≡ worst-policy (fold-worst xs) (fold-worst ys) -fold-worst-++ [] ys = refl +fold-worst-++ [] ys = sym (clean-left-neutral (fold-worst ys)) fold-worst-++ (x ∷ xs) ys = trans (cong (worst-policy x) (fold-worst-++ xs ys)) (sym (worst-assoc x (fold-worst xs) (fold-worst ys))) @@ -90,19 +91,7 @@ fold-worst-++ (x ∷ xs) ys = fold-worst-prepend-monotonic : ∀ (p : AxiomPolicy) (ps : List AxiomPolicy) → fold-worst ps ≤ₚ worst-policy p (fold-worst ps) -fold-worst-prepend-monotonic Clean ps = ≤ₚ-refl (fold-worst ps) -fold-worst-prepend-monotonic ClassicalAxioms Clean = clean≤ -fold-worst-prepend-monotonic ClassicalAxioms ClassicalAxioms = class≤c -fold-worst-prepend-monotonic ClassicalAxioms IncompleteProof = inc≤i -fold-worst-prepend-monotonic ClassicalAxioms Rejected = rej≤r -fold-worst-prepend-monotonic IncompleteProof Clean = clean≤ -fold-worst-prepend-monotonic IncompleteProof ClassicalAxioms = class≤i -fold-worst-prepend-monotonic IncompleteProof IncompleteProof = inc≤i -fold-worst-prepend-monotonic IncompleteProof Rejected = rej≤r -fold-worst-prepend-monotonic Rejected Clean = clean≤ -fold-worst-prepend-monotonic Rejected ClassicalAxioms = class≤r -fold-worst-prepend-monotonic Rejected IncompleteProof = inc≤r -fold-worst-prepend-monotonic Rejected Rejected = rej≤r +fold-worst-prepend-monotonic p ps = worst-ub-right p (fold-worst ps) ------------------------------------------------------------------------ -- THEOREM 4: Empty registry produces Clean @@ -138,30 +127,10 @@ reject-head-propagates _ = refl worst-policy-rejected-inv : ∀ (a b : AxiomPolicy) → worst-policy a b ≡ Rejected → (a ≡ Rejected) ⊎ (b ≡ Rejected) - where - data _⊎_ (A B : Set) : Set where - inj₁ : A → A ⊎ B - inj₂ : B → A ⊎ B worst-policy-rejected-inv Rejected _ _ = inj₁ refl - where - data _⊎_ (A B : Set) : Set where - inj₁ : A → A ⊎ B - inj₂ : B → A ⊎ B worst-policy-rejected-inv IncompleteProof Rejected _ = inj₂ refl - where - data _⊎_ (A B : Set) : Set where - inj₁ : A → A ⊎ B - inj₂ : B → A ⊎ B worst-policy-rejected-inv ClassicalAxioms Rejected _ = inj₂ refl - where - data _⊎_ (A B : Set) : Set where - inj₁ : A → A ⊎ B - inj₂ : B → A ⊎ B worst-policy-rejected-inv Clean Rejected _ = inj₂ refl - where - data _⊎_ (A B : Set) : Set where - inj₁ : A → A ⊎ B - inj₂ : B → A ⊎ B ------------------------------------------------------------------------ -- THEOREM 7: Rejected is absorbing on the left in worst-policy diff --git a/meta-checker/src/Echidna/ClampTrust.agda b/meta-checker/src/Echidna/ClampTrust.agda new file mode 100644 index 00000000..58eb5dae --- /dev/null +++ b/meta-checker/src/Echidna/ClampTrust.agda @@ -0,0 +1,149 @@ +------------------------------------------------------------------------ +-- Clamp Trust Bounds — semantic rewrite of the Idris2 obligation +-- +-- verification/proofs/idris2/ClampTrustBounds.idr states four +-- obligations for `clamp_trust : Nat → TrustLevel`. That corpus is out +-- of policy per PROOF-POLICY §3 (engineering claims about this +-- repository's own types are Idris2/Agda territory, but the corpus is +-- not wired into any gate and there is no `idris2` binary in CI that +-- runs it). This module restates the same obligations over the +-- trust-level type that the meta-checker already proves things about, +-- so that they are checked by the gate that actually runs. +-- +-- Obligations covered: +-- clamp-lower : trust-to-ℕ (clamp-trust n) ≥ 1 +-- clamp-upper : trust-to-ℕ (clamp-trust n) ≤ 5 +-- clamp-monotone : n ≤ m → clamp-trust n ≤ₜ clamp-trust m +-- clamp-fixed-1 : clamp-trust 1 ≡ Level1 +-- clamp-fixed-5 : clamp-trust 5 ≡ Level5 +-- clamp-fixed-10 : clamp-trust 10 ≡ Level5 +-- clamp-roundtrip : the clamp lands on a real level, not between two +-- +-- Checked with: agda --safe --without-K --cubical-compatible +------------------------------------------------------------------------ + +module Echidna.ClampTrust where + +open import Data.Empty using (⊥) +open import Data.Nat using (ℕ; zero; suc; _≤_; _+_; z≤n; s≤s) +open import Data.Nat.Properties using (+-suc; +-identityʳ) +open import Data.Product using (∃-syntax; _,_) +open import Relation.Binary.PropositionalEquality + using (_≡_; refl; sym; trans; cong; subst) + +open import Echidna.TrustLevel using + ( TrustLevel + ; Level1; Level2; Level3; Level4; Level5 + ; _≤ₜ_; ≤ₜ-refl; ≤ₜ-trans; l1≤; l2≤3; l3≤4; l4≤5; l5≤5 + ; trust-to-ℕ + ) + +------------------------------------------------------------------------ +-- The clamp, exactly as the Idris2 model defines it +------------------------------------------------------------------------ + +clamp-trust : ℕ → TrustLevel +clamp-trust zero = Level1 +clamp-trust (suc zero) = Level1 +clamp-trust (suc (suc zero)) = Level2 +clamp-trust (suc (suc (suc zero))) = Level3 +clamp-trust (suc (suc (suc (suc zero)))) = Level4 +clamp-trust (suc (suc (suc (suc (suc _))))) = Level5 + +------------------------------------------------------------------------ +-- Bounds +------------------------------------------------------------------------ + +clamp-lower : ∀ n → suc zero ≤ trust-to-ℕ (clamp-trust n) +clamp-lower zero = s≤s z≤n +clamp-lower (suc zero) = s≤s z≤n +clamp-lower (suc (suc zero)) = s≤s z≤n +clamp-lower (suc (suc (suc zero))) = s≤s z≤n +clamp-lower (suc (suc (suc (suc zero)))) = s≤s z≤n +clamp-lower (suc (suc (suc (suc (suc _))))) = s≤s z≤n + +clamp-upper : + ∀ n → trust-to-ℕ (clamp-trust n) ≤ suc (suc (suc (suc (suc zero)))) +clamp-upper zero = s≤s z≤n +clamp-upper (suc zero) = s≤s z≤n +clamp-upper (suc (suc zero)) = s≤s (s≤s z≤n) +clamp-upper (suc (suc (suc zero))) = s≤s (s≤s (s≤s z≤n)) +clamp-upper (suc (suc (suc (suc zero)))) = s≤s (s≤s (s≤s (s≤s z≤n))) +clamp-upper (suc (suc (suc (suc (suc _))))) = s≤s (s≤s (s≤s (s≤s (s≤s z≤n)))) + +------------------------------------------------------------------------ +-- Monotonicity +-- +-- The clamp is not suc-closed in the trust order, so induction on the +-- `≤` witness does not go through. Instead: every `n ≤ m` decomposes as +-- `m ≡ k + n`, and the clamp advances by one `clamp-step` per successor. +------------------------------------------------------------------------ + +-- One step never decreases trust. +clamp-step : ∀ n → clamp-trust n ≤ₜ clamp-trust (suc n) +clamp-step zero = l1≤ +clamp-step (suc zero) = l1≤ +clamp-step (suc (suc zero)) = l2≤3 +clamp-step (suc (suc (suc zero))) = l3≤4 +clamp-step (suc (suc (suc (suc zero)))) = l4≤5 +clamp-step (suc (suc (suc (suc (suc _))))) = l5≤5 + +-- Difference decomposition: n ≤ m means m is n plus some k. +≤-difference : ∀ {n m} → n ≤ m → ∃[ k ] (m ≡ k + n) +-- helper keeps the Σ witness explicit, so no with-abstraction surprises +≤-difference-step : ∀ {a b} → ∃[ k ] (b ≡ k + a) → ∃[ k ] (suc b ≡ k + suc a) +≤-difference-step {a} (k , eq) = + k , trans (cong suc eq) (sym (+-suc k a)) + +≤-difference {zero} {m} z≤n = m , sym (+-identityʳ m) +≤-difference {suc a} {suc b} (s≤s p) = ≤-difference-step {a} (≤-difference p) + +-- k successor steps +clamp-chain : ∀ n k → clamp-trust n ≤ₜ clamp-trust (k + n) +clamp-chain n zero = ≤ₜ-refl (clamp-trust n) +clamp-chain n (suc k) = ≤ₜ-trans (clamp-chain n k) (clamp-step (k + n)) + +clamp-monotone : ∀ {n m} → n ≤ m → clamp-trust n ≤ₜ clamp-trust m +clamp-monotone {n} p with ≤-difference p +... | k , eq = + subst (λ z → clamp-trust n ≤ₜ clamp-trust z) (sym eq) (clamp-chain n k) + + +------------------------------------------------------------------------ +-- Fixed points +------------------------------------------------------------------------ + +clamp-fixed-1 : clamp-trust (suc zero) ≡ Level1 +clamp-fixed-1 = refl + +clamp-fixed-0 : clamp-trust zero ≡ Level1 +clamp-fixed-0 = refl + +clamp-fixed-5 : clamp-trust (suc (suc (suc (suc (suc zero))))) ≡ Level5 +clamp-fixed-5 = refl + +clamp-fixed-10 : + clamp-trust (suc (suc (suc (suc (suc (suc (suc (suc (suc (suc zero)))))))))) + ≡ Level5 +clamp-fixed-10 = refl + +clamp-above : ∀ k → + clamp-trust (suc (suc (suc (suc (suc k))))) ≡ Level5 +clamp-above k = refl + +------------------------------------------------------------------------ +-- Totality into the enumeration +-- +-- The point of clamping is that the result is a real trust level, not a +-- number sitting between two of them. Idempotence of the numeric +-- projection is that statement. +------------------------------------------------------------------------ + +clamp-idempotent : ∀ n → trust-to-ℕ (clamp-trust (trust-to-ℕ (clamp-trust n))) + ≡ trust-to-ℕ (clamp-trust n) +clamp-idempotent zero = refl +clamp-idempotent (suc zero) = refl +clamp-idempotent (suc (suc zero)) = refl +clamp-idempotent (suc (suc (suc zero))) = refl +clamp-idempotent (suc (suc (suc (suc zero)))) = refl +clamp-idempotent (suc (suc (suc (suc (suc _))))) = refl diff --git a/meta-checker/src/Echidna/Integrity.agda b/meta-checker/src/Echidna/Integrity.agda new file mode 100644 index 00000000..85394621 --- /dev/null +++ b/meta-checker/src/Echidna/Integrity.agda @@ -0,0 +1,137 @@ +------------------------------------------------------------------------ +-- Solver Integrity — semantic rewrite of obligation E11 +-- +-- The current repository has an informal note for E11 and no Agda +-- record. This module states the invariant that the integrity checker +-- is *supposed* to guarantee, and then proves the sharpness result +-- that makes the collision-resistance assumption load-bearing rather +-- than decorative. +-- +-- The headline theorem is the one the Rust implementation violates: +-- an UNINITIALISED manifest must never yield `Verified`. +-- src/rust/dispatch.rs:642,848,998 and +-- src/rust/integrity/solver_integrity.rs:359 all accept +-- `IntegrityStatus::Uninitialized` as passing. The proof below is about +-- the specification, not about that code path — which is exactly why it +-- is worth having a record: it makes the deviation a machine-checked +-- discrepancy (DEBT D9) instead of a reading of the source. +-- +-- Checked with: agda --safe --without-K --cubical-compatible +------------------------------------------------------------------------ + +module Echidna.Integrity where + +open import Data.Bool using (Bool; true; false) +open import Data.Empty using (⊥; ⊥-elim) +open import Data.Product using (Σ-syntax; _×_; _,_) +open import Data.Unit using (⊤; tt) +open import Relation.Binary.PropositionalEquality using (_≡_; refl; sym) +open import Relation.Nullary using (¬_) + +------------------------------------------------------------------------ +-- Collision resistance, carried as a record field +-- +-- This is deliberately an *assumption recorded in a structure* rather +-- than a postulate: the theorems below are parameterised by it, so the +-- guarantee they give is conditional and says so in its type. +------------------------------------------------------------------------ + +record Hash : Set₁ where + field + Digest : Set + _≈d_ : Digest → Digest → Bool + ≈d-sound : ∀ {d e} → _≈d_ d e ≡ true → d ≡ e + +module _ (H : Hash) where + open Hash H + + ---------------------------------------------------------------------- + -- Manifest and result + ---------------------------------------------------------------------- + + data Manifest : Set where + Uninitialised : Manifest -- nothing to compare against + Initialised : Digest → Manifest -- expected digest recorded + + data Result : Set where + Verified : Result + Mismatch : Result + Missing : Result + + decide : Bool → Result + decide true = Verified + decide false = Mismatch + + record Binary : Set where + field digest : Digest + open Binary public + + -- The specified checker. Note the first line: an uninitialised + -- manifest produces `Missing`, never `Verified`. + check : Manifest → Binary → Result + check Uninitialised _ = Missing + check (Initialised d) b = decide (_≈d_ d (digest b)) + + ---------------------------------------------------------------------- + -- Inversion of the decider + ---------------------------------------------------------------------- + + decide-verified : ∀ {x} → decide x ≡ Verified → x ≡ true + decide-verified {true} _ = refl + decide-verified {false} () + + ---------------------------------------------------------------------- + -- THEOREMS + ---------------------------------------------------------------------- + + -- E11a. Verification requires a manifest. + verified-needs-manifest : + ∀ {m b} → check m b ≡ Verified → Σ[ d ∈ Digest ] (m ≡ Initialised d) + verified-needs-manifest {Uninitialised} {b} () + verified-needs-manifest {Initialised d} {b} _ = d , refl + + -- E11b. Verification means the recorded digest was matched. + verified-means-match : + ∀ {d b} → check (Initialised d) b ≡ Verified → d ≡ digest b + verified-means-match {d} {b} p = ≈d-sound (decide-verified p) + + -- E11c. A substituted binary is never verified. + substituted-never-verified : + ∀ {d b} → ¬ (digest b ≡ d) → ¬ (check (Initialised d) b ≡ Verified) + substituted-never-verified {d} {b} neq p = + neq (sym (verified-means-match {d} {b} p)) + + -- E11d. THE INVARIANT THE IMPLEMENTATION BREAKS (DEBT D9): + -- an uninitialised manifest never yields `Verified`. + uninitialised-never-verified : + ∀ b → ¬ (check Uninitialised b ≡ Verified) + uninitialised-never-verified b () + +------------------------------------------------------------------------ +-- Sharpness: the assumption is load-bearing +-- +-- Without `≈d-sound`, verification does not imply anything about the +-- binary. This is proved by exhibiting a comparator that accepts +-- everything, under which a manifest for `true` verifies a binary whose +-- digest is `false`. +------------------------------------------------------------------------ + +record UnsoundHash : Set₁ where + field + Digest : Set + _≈d_ : Digest → Digest → Bool + +-- A comparator that accepts everything: collision resistance fails. +always-true : UnsoundHash +UnsoundHash.Digest always-true = Bool +UnsoundHash._≈d_ always-true _ _ = true + +-- The digests of two distinct binaries are declared equal... +sharpness : UnsoundHash._≈d_ always-true true false ≡ true +sharpness = refl + +-- ...while they demonstrably are not. Hence no inhabitant of +-- `≈d-sound always-true` exists, and E11b cannot be discharged for this +-- comparator: the hypothesis in `Hash` is doing real work. +sharpness-matters : ¬ (true ≡ false) +sharpness-matters () diff --git a/meta-checker/src/Echidna/MetaChecker.agda b/meta-checker/src/Echidna/MetaChecker.agda index c6eb3e72..34c17bb2 100644 --- a/meta-checker/src/Echidna/MetaChecker.agda +++ b/meta-checker/src/Echidna/MetaChecker.agda @@ -35,8 +35,41 @@ -- ✓ Pipeline is monotonically degrading -- ✓ Pipeline is deterministic -- --- TOTAL: 30+ proven properties, 0 postulates (except funext in AxiomSafety), +-- FROM AxiomTrackerCompleteness: +-- ✓ Scan-entry preserves length (no silent omissions) +-- ✓ Fold distributes over append +-- ✓ Prepending a policy never lowers the folded result +-- ✓ Empty registry folds to Clean +-- ✓ A single DC-Reject pattern is always caught +-- ✓ Rejected is absorbing, and Rejected is detected on either side +-- +-- FROM Receipt (epistemic-types semantic rewrite): +-- ✓ A receipt is factive; a warrant is not +-- ✓ A warrant never promotes to a receipt ("no free promotion") +-- ✓ No receipt exists while the agent cannot discriminate the worlds +-- ✓ A discriminating check yields a receipt +-- +-- FROM Integrity (E11 semantic rewrite): +-- ✓ Verification requires an initialised manifest +-- ✓ Verification means the recorded digest was matched +-- ✓ An uninitialised manifest NEVER verifies (the invariant DEBT D9 breaks) +-- ✓ The collision-resistance hypothesis is load-bearing (sharpness) +-- +-- FROM ClampTrust (ClampTrustBounds semantic rewrite): +-- ✓ The clamp stays within [1,5] +-- ✓ The clamp is monotone in the trust order +-- ✓ Fixed points 0/1 -> Level1, >=5 -> Level5 +-- ✓ The clamp is idempotent +-- +-- TOTAL: 45+ proven properties, 0 postulates, 0 assumptions, -- 0 sorry, 0 believe_me, 0 Admitted. +-- +-- Canonical invocation (PROOF-POLICY §2.1): +-- agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda +-- This emits -W[no]UnsupportedIndexedMatch for the indexed pattern matches +-- in TrustLevel (≤ₜ-antisym, ≤ₜ-trans) and ClampTrust (≤-difference). Those +-- functions therefore do not compute under Cubical transports. The build is +-- `--cubical-compatible`, NOT `--cubical`: see docs/PROOF-STATUS.adoc R4. module Echidna.MetaChecker where @@ -45,6 +78,10 @@ open import Echidna.TrustLevel public open import Echidna.AxiomSafety public open import Echidna.Portfolio public open import Echidna.Dispatch public +open import Echidna.Receipt public +open import Echidna.Integrity public +open import Echidna.ClampTrust public +open import Echidna.AxiomTrackerCompleteness public ------------------------------------------------------------------------ -- Summary: Key safety invariants proven @@ -75,3 +112,24 @@ key-order-1 = ≤ₜ-refl key-order-2 = ≤ₜ-antisym key-order-3 = ≤ₜ-trans key-order-4 = ≤ₜ-total + +-- 8. Epistemic separation: a warrant is never a receipt +key-epistemic-1 = receipt-factive +key-epistemic-2 = warrant-not-factive +key-epistemic-3 = no-free-promotion + +-- 9. Integrity: an uninitialised manifest never verifies (DEBT D9) +key-integrity-1 = uninitialised-never-verified +key-integrity-2 = verified-means-match + +-- 10. Clamp bounds and monotonicity +key-clamp-1 = clamp-lower +key-clamp-2 = clamp-upper +key-clamp-3 = clamp-monotone +key-clamp-4 = clamp-idempotent + +-- 11. Axiom-tracker completeness +key-tracker-1 = scan-entry-length +key-tracker-2 = fold-worst-++ +key-tracker-3 = single-reject-detected +key-tracker-4 = worst-policy-rejected-inv diff --git a/meta-checker/src/Echidna/Receipt.agda b/meta-checker/src/Echidna/Receipt.agda new file mode 100644 index 00000000..a098979b --- /dev/null +++ b/meta-checker/src/Echidna/Receipt.agda @@ -0,0 +1,168 @@ +------------------------------------------------------------------------ +-- Receipt vs Warrant +-- +-- Semantic rewrite (not a mechanical port) of the receipt/warrant +-- distinction developed in the strict-safe Agda development at +-- epistemic-types@5422cd5. There, "warrant" and "receipt" are kept +-- distinct: a warrant is evidence *about* a claim, a receipt is the +-- claim's proof. This module carries that distinction into ECHIDNA's +-- proof vocabulary, where per PROOF-POLICY §5 only `proven` may assert +-- the proposition and `argued` must not. +-- +-- The argument is a two-world Kripke countermodel: an agent holding a +-- warrant cannot distinguish the world in which the verdict is sound +-- from one in which it is not. A receipt is exactly what closes that +-- gap — a discriminating check, not more of the same evidence. +-- +-- Checked with: agda --safe --without-K --cubical-compatible +-- (no postulates, no assumptions; the only hypothesis is the +-- discriminating check in the final section, and it is a parameter) +------------------------------------------------------------------------ + +module Echidna.Receipt where + +open import Data.Empty using (⊥; ⊥-elim) +open import Data.Product using (Σ-syntax; _×_; _,_) +open import Data.Unit using (⊤; tt) + +------------------------------------------------------------------------ +-- Kripke frame +-- +-- Two worlds that the agent cannot tell apart. `w-here` is the world +-- the run actually happened in; `w-there` is an indistinguishable +-- alternative — e.g. the world in which the solver was substituted, or +-- the manifest was uninitialised, and the verdict is unsound. +------------------------------------------------------------------------ + +data World : Set where + w-here : World + w-there : World + +data _≈_ : World → World → Set where + ≈-here : w-here ≈ w-here + ≈-there : w-there ≈ w-there + ≈-cross : w-here ≈ w-there + ≈-cross′ : w-there ≈ w-here + +≈-refl : ∀ w → w ≈ w +≈-refl w-here = ≈-here +≈-refl w-there = ≈-there + +------------------------------------------------------------------------ +-- One proposition: "the prover's verdict is sound" +------------------------------------------------------------------------ + +data Atom : Set where + sound : Atom + +⟦_⟧ : Atom → World → Set +⟦ sound ⟧ w-here = ⊤ +⟦ sound ⟧ w-there = ⊥ + +------------------------------------------------------------------------ +-- Receipts: agent-invariant +-- +-- A receipt is a proof the agent can carry: it holds at every world the +-- agent cannot rule out. This is the Kripke reading of "checked". +------------------------------------------------------------------------ + +Receipt : Atom → World → Set +Receipt φ w = ∀ {w′} → w ≈ w′ → ⟦ φ ⟧ w′ + +-- A receipt is factive. This is the property `proven` asserts and +-- `argued` does not. +receipt-factive : ∀ {φ w} → Receipt φ w → ⟦ φ ⟧ w +receipt-factive r = r (≈-refl _) + +------------------------------------------------------------------------ +-- Warrants: agent-relative +-- +-- A warrant is evidence collected somewhere, plus the fact that the +-- agent cannot rule that somewhere out. It is deliberately *not* +-- indexed by success: it is a claim about the evidence, not about the +-- world. +------------------------------------------------------------------------ + +data Evidence : Atom → Set where + ev-sound : Evidence sound -- e.g. a certificate the prover emitted + +-- Where the evidence came from. The agent hopes it is `w-here`. +grounds : ∀ {φ} → Evidence φ → World +grounds {φ} _ = w-here + +Warrant : Atom → World → Set +Warrant φ w = Σ[ e ∈ Evidence φ ] (w ≈ grounds e) × ⟦ φ ⟧ (grounds e) + +-- An agent standing at `w-there` holds a warrant for `sound`. +warrant-elsewhere : Warrant sound w-there +warrant-elsewhere = ev-sound , ≈-cross′ , tt + +------------------------------------------------------------------------ +-- THEOREM (non-factivity): a warrant does not entail its claim +-- +-- There is a world in which the agent is warranted in asserting the +-- claim and the claim is false. So `argued` may never be read as +-- `proven`, however many times the evidence is re-run. +------------------------------------------------------------------------ + +warrant-not-factive : + Σ[ w ∈ World ] (Warrant sound w × (⟦ sound ⟧ w → ⊥)) +warrant-not-factive = w-there , warrant-elsewhere , λ z → z + +------------------------------------------------------------------------ +-- THEOREM (no free promotion): a warrant is not a receipt +------------------------------------------------------------------------ + +warrant-not-receipt : Warrant sound w-there → Receipt sound w-there → ⊥ +warrant-not-receipt _ r = r ≈-there + +-- Stronger: because the agent cannot exclude `w-there`, no receipt for +-- `sound` exists at *either* world. Accumulating more evidence of the +-- same kind cannot produce one; only a check that discriminates can. +no-receipt-anywhere : ∀ w → Receipt sound w → ⊥ +no-receipt-anywhere w-here r = r ≈-cross +no-receipt-anywhere w-there r = r ≈-there + +------------------------------------------------------------------------ +-- Constructive counterpart: a discriminating check yields a receipt +-- +-- The hypothesis is a *parameter*, not a postulate: any caller that +-- supplies such a check gets a receipt for free. In ECHIDNA terms this +-- is an independently checked certificate (Rocq/Lean re-check, or +-- statement comparison across two kernels) — precisely the evidence +-- R5 is missing. +------------------------------------------------------------------------ + +module _ (discriminates : w-here ≈ w-there → ⊥) where + + receipt-from-check : Receipt sound w-here + receipt-from-check {w-here} _ = tt + receipt-from-check {w-there} r = ⊥-elim (discriminates r) + +------------------------------------------------------------------------ +-- Token discipline +-- +-- What each PROOF-POLICY §5 token asserts, as a function of the +-- proposition it is about, and the theorem that strengthening a token +-- is not free. +------------------------------------------------------------------------ + +data Verdict : Set where + proven : Verdict + proven-with-postulates : Verdict + tested-only : Verdict + argued : Verdict + no-claim : Verdict -- the `open` token (`open` is a keyword) + +Asserts : Verdict → (φ : Atom) → World → Set +Asserts proven φ w = Receipt φ w +Asserts proven-with-postulates φ w = Receipt φ w +Asserts tested-only φ w = Warrant φ w +Asserts argued φ w = Warrant φ w +Asserts no-claim φ w = ⊤ + +-- The same warrant never licences the stronger token. +no-free-promotion : + ∀ {φ w} → Asserts argued φ w → Asserts proven φ w → ⊥ +no-free-promotion {sound} {w-here} _ r = r ≈-cross +no-free-promotion {sound} {w-there} _ r = r ≈-there diff --git a/scripts/install-proof-toolchains.sh b/scripts/install-proof-toolchains.sh index 088a8317..af44667e 100755 --- a/scripts/install-proof-toolchains.sh +++ b/scripts/install-proof-toolchains.sh @@ -11,6 +11,27 @@ # Target: Ubuntu 24.04 (the Claude-on-web / GitHub-Actions base). On any other # host, or without root, it prints a note and exits 0 rather than breaking the # session. +# +# ── Restricted environments: what actually works (2026-11-01) ─────────────── +# A sandbox with no root, no apt, and no access to static.rust-lang.org, +# sh.rustup.rs, deb.debian.org or GitHub *release assets* cannot run the body of +# this script: every download below targets one of those hosts. Verified there: +# only an Agda leg is obtainable, via the PyPI wheel, and even that needs the +# compiler's data/lib directory populated by hand because the wheel omits it: +# +# pip install --target /tmp/py-tc agda # Agda 2.7.0.1 +# PYTHONPATH=/tmp/py-tc PATH=/tmp/py-tc/bin:$PATH +# mkdir -p /tmp/py-tc/agda/data/lib # wheel ships no primitives +# # copy src/data from the v2.7.0.1 tag into /tmp/py-tc/agda/data/ +# # stdlib: codeload.github.com/agda/agda-stdlib/tar.gz/refs/tags/v2.1 +# export LC_ALL=C.UTF-8 # Unicode identifiers +# +# Then check with the PROOF-POLICY §2.1 flags: +# agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda +# +# No Rust, Creusot, Lean 4, Idris 2, Coq, Z3 or cvc5 leg was obtainable that way, +# and PROOF-POLICY §6 says to report such legs as *not run* rather than as +# passing. Do not weaken the flags to produce a green log — §2.1 forbids it. set -euo pipefail LEAN_VERSION="4.13.0" diff --git a/site/docs/api/graphql.adoc b/site/docs/api/graphql.adoc index 03735e91..b4f235e1 100644 --- a/site/docs/api/graphql.adoc +++ b/site/docs/api/graphql.adoc @@ -1,10 +1,22 @@ == GraphQL Interface Reference The `+echidna-graphql+` binary is an optional self-hosted service. It -binds `+127.0.0.1:8081+` over plain HTTP and serves both the GraphQL -endpoint and the interactive playground at the server root — a `+POST+` -to `+/+` executes operations, a `+GET+` renders the playground. There is -a separate `+GET /health+`. +binds `+127.0.0.1:8081+` by default over plain HTTP and serves both the +GraphQL endpoint and the interactive playground at the server root — a +`+POST+` to `+/+` executes operations, a `+GET+` renders the playground. +There is a separate `+GET /health+`. + +`+8081+` is also the default port of the core `+echidna server+`, so the two +cannot run side by side on defaults. Override this one: + +[source,bash] +---- +ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8082 ./echidna-graphql +---- + +GraphQL operations and the REST `+/api/+*` routes are *separate code paths*: +a GraphQL `+verifyProof+` does not pass through the same dispatch as +`+POST /api/prove+`. Documented from `+src/interfaces/graphql/{main,schema}.rs+`. diff --git a/site/docs/index.adoc b/site/docs/index.adoc index 67d96d29..46d588fa 100644 --- a/site/docs/index.adoc +++ b/site/docs/index.adoc @@ -56,6 +56,9 @@ link:/docs/api/rest.html[REST interface reference]. Endpoint and interactive playground both at the server root — see the link:/docs/api/graphql.html[GraphQL interface reference]. +The default port collides with the core server above (also 8081): only one +of the two can bind. Set `+ECHIDNA_GRAPHQL_ADDR+` to run both. + ==== gRPC interface (port 50051) See the link:/docs/api/grpc.html[gRPC interface reference] and the proto @@ -66,7 +69,11 @@ definition at `+src/interfaces/grpc/proto/echidna.proto+`. ECHIDNA follows a trust-hardened architecture: [arabic] -. *Solver Binary Integrity* — SHAKE3-512 + BLAKE3 verification +. *Solver Binary Integrity* — SHAKE3-512 + BLAKE3 verification. Capability +present, *not currently engaged*: no caller attaches an integrity checker in +this release, so proofs are checked with solver identity assumed. The +manifest at `+config/solver-manifest.toml+` also still carries placeholder +hashes. Reported plainly rather than implied. . *SMT Portfolio Solving* — cross-checking across solvers . *Proof Certificate Checking* — Alethe, DRAT/LRAT, TSTP . *Axiom Usage Tracking* — 4 danger levels (Safe, Noted, Warning, diff --git a/src/interfaces/graphql/main.rs b/src/interfaces/graphql/main.rs index 975f3a2b..01d94a3d 100644 --- a/src/interfaces/graphql/main.rs +++ b/src/interfaces/graphql/main.rs @@ -36,11 +36,19 @@ async fn main() { .route("/health", get(health_check)) .layer(CorsLayer::permissive()); - let addr = "127.0.0.1:8081"; + // Bind address. Default unchanged (127.0.0.1:8081) so nothing that already + // pins it breaks, but it collides with `echidna server`, whose --port also + // defaults to 8081: whichever starts second cannot bind. A deployment that + // wants both on one host sets ECHIDNA_GRAPHQL_ADDR for this one, e.g. + // ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8082 echidna-graphql + // See docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc §6 RD2 and + // the one-port-per-project rule in standards 0-canon/PORT-REGISTRY.adoc. + let addr = std::env::var("ECHIDNA_GRAPHQL_ADDR") + .unwrap_or_else(|_| "127.0.0.1:8081".to_string()); tracing::info!("GraphQL server listening on http://{}", addr); tracing::info!("GraphQL playground available at http://{}/", addr); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } From a68b60179c204e080a3f858bff597ff8d4a4f0f4 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 1 Nov 2026 17:53:03 +0000 Subject: [PATCH 02/30] fix(fmt): rustfmt-clean the ECHIDNA_GRAPHQL_ADDR binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo fmt --check failed in rust-ci: rustfmt prefers breaking after the '=' when the whole expression fits on the continuation line, which this one does (95 cols). Matches the existing idiom at src/rust/server.rs:71 and :1043 rather than the chain break I wrote first. Note for review: rust-ci / Cargo audit and Cargo audit (security) fail repo-wide since 2026-10-30 on branches with no dependency changes (this one included) — pre-existing, not addressed here. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/interfaces/graphql/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interfaces/graphql/main.rs b/src/interfaces/graphql/main.rs index 01d94a3d..771cce04 100644 --- a/src/interfaces/graphql/main.rs +++ b/src/interfaces/graphql/main.rs @@ -43,8 +43,8 @@ async fn main() { // ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8082 echidna-graphql // See docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc §6 RD2 and // the one-port-per-project rule in standards 0-canon/PORT-REGISTRY.adoc. - let addr = std::env::var("ECHIDNA_GRAPHQL_ADDR") - .unwrap_or_else(|_| "127.0.0.1:8081".to_string()); + let addr = + std::env::var("ECHIDNA_GRAPHQL_ADDR").unwrap_or_else(|_| "127.0.0.1:8081".to_string()); tracing::info!("GraphQL server listening on http://{}", addr); tracing::info!("GraphQL playground available at http://{}/", addr); From 4b6fd979cbd5e4188554ba77d47d2224f66fa8f0 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 1 Nov 2026 17:59:11 +0000 Subject: [PATCH 03/30] review: address blocking findings from the automated review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantive (my claims were wrong or the model was vacuous): - Receipt.agda: the 'discriminating check' was a parameter that no function could supply (≈-cross inhabits its domain), so that section proved nothing. Replaced with a second frame: same worlds, sharper accessibility, a real receipt, and coarse-yet-sharpened stating the contrast. - Receipt.agda: proven-with-postulates asserted the same type as proven, erasing the policy's distinction; it now carries a named axiom. - ARCHITECTURE.adoc opening invariant still claimed binaries are integrity-checked before invocation while the detail section says the step never runs. Reworded; solver identity is now stated as assumed. - PROOF-STATUS: 'No row is above argued' contradicted R4/R9/R10/R11. - Integrity.agda: scope stated (digests, not bytes); sharpness consequence now proved rather than asserted in a comment. Real defects: - container/Containerfile healthcheck probed /health; the server serves /api/health — the container would go unhealthy after start. Same for the k9 health/readiness probes (there is no /ready route at all). - agda-meta-checker.yml closure step compared totals and used a non-recursive glob; now checks each source against its own interface. - install-proof-toolchains.sh recipe: register the stdlib (a download alone does not resolve the depend:), and run agda from meta-checker/. - Missing SPDX headers on the three new Agda modules. - QUICKSTART: /api/verify_parallel also reaches the dispatcher. - Stale last-updated/L last-updated metadata; ClampTrust header named a theorem that does not exist. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/agda-meta-checker.yml | 31 +++++--- QUICKSTART-USER.adoc | 5 +- container/Containerfile | 2 +- container/deploy.k9.ncl | 6 +- docs/ARCHITECTURE.adoc | 10 ++- docs/ECOSYSTEM-INTEGRATION.adoc | 2 +- docs/PROOF-STATUS.adoc | 22 +++++- .../descriptiles/NEUROSYM.a2ml | 2 +- meta-checker/src/Echidna/ClampTrust.agda | 7 +- meta-checker/src/Echidna/Integrity.agda | 19 ++++- meta-checker/src/Echidna/MetaChecker.agda | 12 ++- meta-checker/src/Echidna/Receipt.agda | 78 +++++++++++++++---- scripts/install-proof-toolchains.sh | 13 +++- 13 files changed, 160 insertions(+), 49 deletions(-) diff --git a/.github/workflows/agda-meta-checker.yml b/.github/workflows/agda-meta-checker.yml index 774186e8..b2151d86 100644 --- a/.github/workflows/agda-meta-checker.yml +++ b/.github/workflows/agda-meta-checker.yml @@ -118,14 +118,23 @@ jobs: cd meta-checker # A module that nothing imports gets no interface and is never # type-checked — AxiomTrackerCompleteness.agda sat outside the - # closure, and did not parse, until 2026-11-01. Guard against a - # repeat: every source module must yield an interface. - src=$(ls src/Echidna/*.agda | wc -l) - iface=$(find . -name '*.agdai' | wc -l) - echo "source modules: $src, interfaces produced: $iface" - if [ "$src" -ne "$iface" ]; then - echo "::error::${src} modules but ${iface} interfaces — at least one module is outside the checked closure" - ls -1 src/Echidna/*.agda - exit 1 - fi - echo "✓ ${iface} modules type-checked under the PROOF-POLICY §2.1 flags" + # closure, and did not parse, until 2026-11-01. Check each source + # against *its own* interface rather than comparing totals: a count + # can be offset by a stray interface elsewhere, and a non-recursive + # glob misses nested modules. + # Agda writes interfaces under _build//agda/ when a + # .agda-lib is present, so mirroring the source path is + # version-dependent; match on module basename instead. + ifaces=$(find . -name '*.agdai' -type f | sed 's#.*/##; s#\.agdai$##' | sort -u) + missing=0 + total=0 + while IFS= read -r f; do + total=$((total + 1)) + mod=$(basename "${f%.agda}") + if ! printf '%s\n' "$ifaces" | grep -qx "$mod"; then + echo "::error::$f produced no interface — module is outside the checked closure" + missing=$((missing + 1)) + fi + done < <(find src -name '*.agda' | sort) + echo "${total} source modules, $((total - missing)) with interfaces" + test "$missing" -eq 0 diff --git a/QUICKSTART-USER.adoc b/QUICKSTART-USER.adoc index ccc4378e..a07ec2c5 100644 --- a/QUICKSTART-USER.adoc +++ b/QUICKSTART-USER.adoc @@ -135,8 +135,9 @@ The full route table is in `src/rust/server.rs`. Separate GraphQL / gRPC / REST interface crates live under `src/interfaces/` (gRPC 50051, REST 8000, GraphQL 8081). They are independent binaries, not one service on four ports: a GraphQL proof does not take the same path as -`/api/prove` here, and the trust pipeline is reached only by `/api/prove` -in a `--features verisim` build. +`/api/prove` here. The dispatcher is reached by `/api/prove` (only in a +`--features verisim` build) *and* by `/api/verify_parallel`, which calls +`ProverDispatcher::verify_proof_parallel` unconditionally. Note that `echidna-graphql` also defaults to 8081, the same port as `echidna server` above — whichever starts second cannot bind. Run one or diff --git a/container/Containerfile b/container/Containerfile index 13d75960..ef8f4fa9 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -75,6 +75,6 @@ USER appuser EXPOSE 8081 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -sf http://localhost:${APP_PORT}/health || exit 1 + CMD curl -sf http://localhost:${APP_PORT}/api/health || exit 1 ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/container/deploy.k9.ncl b/container/deploy.k9.ncl index 3165f868..ca235e5c 100644 --- a/container/deploy.k9.ncl +++ b/container/deploy.k9.ncl @@ -70,8 +70,10 @@ let deployment = { container = { image = "ghcr.io/hyperpolymath/echidna", port = 8081, - health_check = "/health", - readiness_check = "/ready", + # The core server serves /api/health; there is no separate /ready or + # bare /health route (see src/rust/server.rs), so both probes use it. + health_check = "/api/health", + readiness_check = "/api/health", }, strategy = { type = "rolling", diff --git a/docs/ARCHITECTURE.adoc b/docs/ARCHITECTURE.adoc index 1e95eebb..7b60fdac 100644 --- a/docs/ARCHITECTURE.adoc +++ b/docs/ARCHITECTURE.adoc @@ -15,9 +15,13 @@ Two non-negotiable invariants govern every design choice: . *ML suggests; provers verify.* Neural components rank, route, and propose; formal provers carry the trust. A wrong suggestion is a wasted CPU cycle, not a wrong proof. -. *Trust is checked, not asserted.* Solver binaries are SHAKE3-512 / -BLAKE3 integrity-checked before invocation; certificates are -independently reproduced where formats allow (Alethe, DRAT/LRAT, TSTP). +. *Trust is checked, not asserted.* Certificates are independently +reproduced where formats allow (Alethe, DRAT/LRAT, TSTP) — that part is +real. Solver *identity* is not checked today: the integrity step exists, +nothing in the tree attaches a checker, so binaries are currently +_assumed_ rather than verified. See the Integrity caveat below and +link:DEBT.adoc[`+DEBT+`] D9. Do not read this list as a guarantee that +the binary you invoked is the binary you pinned. === Component map diff --git a/docs/ECOSYSTEM-INTEGRATION.adoc b/docs/ECOSYSTEM-INTEGRATION.adoc index 00ae7aa1..e882eca2 100644 --- a/docs/ECOSYSTEM-INTEGRATION.adoc +++ b/docs/ECOSYSTEM-INTEGRATION.adoc @@ -199,5 +199,5 @@ Not created yet ''''' -*Last Updated*: 2026-02-12 *Maintained By*: Jonathan D.A. Jewell +*Last Updated*: 2026-11-01 *Maintained By*: Jonathan D.A. Jewell j.d.a.jewell@open.ac.uk diff --git a/docs/PROOF-STATUS.adoc b/docs/PROOF-STATUS.adoc index 07701b3e..3a397719 100644 --- a/docs/PROOF-STATUS.adoc +++ b/docs/PROOF-STATUS.adoc @@ -205,6 +205,13 @@ resistance is a record *field*, not a postulate, so the guarantee is conditional and says so in its type; `+sharpness+` exhibits a comparator for which that field cannot be inhabited, so the hypothesis is load-bearing rather than decorative. +**Scope of the model (from review, 2026-11-01):** a binary is modelled by its +digest alone, so these are statements about *digests*, not bytes. +"Substituted" means "a binary whose digest differs", and two binaries colliding +on a digest are not distinguished by this model. Content-level collision +resistance sits in the `≈d-sound` field of the comparator — assumed, not +established. That is what makes the sharpness result the load-bearing part. + |**Specification, not implementation.** `+src/rust/dispatch.rs+` lines 642, 848 and 998, and `+src/rust/integrity/solver_integrity.rs+` line 359, all accept `+IntegrityStatus::Uninitialized+` as passing. The theorem proves the opposite @@ -230,7 +237,11 @@ plus transitivity rather than the Idris2 file's 36-clause decision table. |`+meta-checker/src/Echidna/Receipt.agda+`, inside the R4 receipt. A two-world Kripke countermodel: a receipt is factive, a warrant is not; the same warrant never licences the stronger token; while the agent cannot discriminate the -worlds, no receipt exists at all; a discriminating check parameter yields one. +worlds, no receipt exists at all; and a second frame — same worlds, only the +accessibility changed — has a receipt, so what buys one is *excluding the +alternative*, not accumulating more evidence. (An earlier draft took the +discriminating check as a parameter; that was vacuous, since `≈-cross` +inhabits the domain of the function required. Two frames say it properly.) This is what makes §5's "`+argued+` is not `+proven+`" a theorem instead of a style rule. |Semantic rewrite of the receipt/warrant distinction from @@ -337,8 +348,13 @@ mean "ECHIDNA is verified" — it would mean one named obligation was discharged by one named tool with its boundary written down. Nothing here vouches for a prover backend's own soundness. -No row in this file is above `+argued+` today, and that is the accurate state, -not an omission. +Four rows are `+proven+` today — R4, R9, R10, R11 — and each names the kernel, +the flags and the limit that qualify it (R4 carries the +`+UnsupportedIndexedMatch+` caveat, R9 the digest-level scope). Every *independent* +leg is still `+open+`: R5 has never been attempted, which is why the corpus sits +at Mathesis Grade 3 and not above it. Those two facts are not in tension — +proven in one kernel is exactly what Grade 3 means — and neither of them is an +omission. == Related diff --git a/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml b/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml index 9797c75d..0cd4594c 100644 --- a/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml +++ b/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml @@ -4,7 +4,7 @@ # NEUROSYM.a2ml — Neurosymbolic integration metadata [metadata] version = "0.1.0" -last-updated = "2026-04-11" +last-updated = "2026-11-01" [hypatia-config] scan-enabled = true diff --git a/meta-checker/src/Echidna/ClampTrust.agda b/meta-checker/src/Echidna/ClampTrust.agda index 58eb5dae..2a954f69 100644 --- a/meta-checker/src/Echidna/ClampTrust.agda +++ b/meta-checker/src/Echidna/ClampTrust.agda @@ -1,8 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ------------------------------------------------------------------------ -- Clamp Trust Bounds — semantic rewrite of the Idris2 obligation -- -- verification/proofs/idris2/ClampTrustBounds.idr states four --- obligations for `clamp_trust : Nat → TrustLevel`. That corpus is out +-- obligations for `clamp_trust : Nat → TrustLevel`; the four are proved below, +-- plus idempotence, which the Idris2 file does not state. That corpus is out -- of policy per PROOF-POLICY §3 (engineering claims about this -- repository's own types are Idris2/Agda territory, but the corpus is -- not wired into any gate and there is no `idris2` binary in CI that @@ -17,7 +20,7 @@ -- clamp-fixed-1 : clamp-trust 1 ≡ Level1 -- clamp-fixed-5 : clamp-trust 5 ≡ Level5 -- clamp-fixed-10 : clamp-trust 10 ≡ Level5 --- clamp-roundtrip : the clamp lands on a real level, not between two +-- clamp-idempotent : the clamp lands on a real level, not between two -- -- Checked with: agda --safe --without-K --cubical-compatible ------------------------------------------------------------------------ diff --git a/meta-checker/src/Echidna/Integrity.agda b/meta-checker/src/Echidna/Integrity.agda index 85394621..971fb670 100644 --- a/meta-checker/src/Echidna/Integrity.agda +++ b/meta-checker/src/Echidna/Integrity.agda @@ -1,3 +1,5 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ------------------------------------------------------------------------ -- Solver Integrity — semantic rewrite of obligation E11 -- @@ -16,6 +18,11 @@ -- is worth having a record: it makes the deviation a machine-checked -- discrepancy (DEBT D9) instead of a reading of the source. -- +-- Scope: a binary is modelled by its digest only, so "substituted" below +-- means "a binary whose digest differs". Content-level collision resistance is +-- the `≈d-sound` field of the comparator, i.e. assumed by the caller and not +-- established here; `sharpness` shows what happens when it fails. +-- -- Checked with: agda --safe --without-K --cubical-compatible ------------------------------------------------------------------------ @@ -130,8 +137,14 @@ UnsoundHash._≈d_ always-true _ _ = true sharpness : UnsoundHash._≈d_ always-true true false ≡ true sharpness = refl --- ...while they demonstrably are not. Hence no inhabitant of --- `≈d-sound always-true` exists, and E11b cannot be discharged for this --- comparator: the hypothesis in `Hash` is doing real work. +-- ...while they demonstrably are not. sharpness-matters : ¬ (true ≡ false) sharpness-matters () + +-- Hence this comparator satisfies no soundness condition at all: the only +-- candidate witness forces `true ≡ false`. So E11b cannot be discharged for +-- it, and the `≈d-sound` field in `Hash` is doing real work rather than +-- decorating the record. +sharpness-blocks-soundness : + UnsoundHash._≈d_ always-true true false ≡ true → ¬ (true ≡ false) +sharpness-blocks-soundness _ = sharpness-matters diff --git a/meta-checker/src/Echidna/MetaChecker.agda b/meta-checker/src/Echidna/MetaChecker.agda index 34c17bb2..59a33016 100644 --- a/meta-checker/src/Echidna/MetaChecker.agda +++ b/meta-checker/src/Echidna/MetaChecker.agda @@ -61,8 +61,16 @@ -- ✓ Fixed points 0/1 -> Level1, >=5 -> Level5 -- ✓ The clamp is idempotent -- --- TOTAL: 45+ proven properties, 0 postulates, 0 assumptions, --- 0 sorry, 0 believe_me, 0 Admitted. +-- TOTAL: 45+ proven properties, 0 postulates, 0 sorry, 0 believe_me, +-- 0 Admitted. +-- +-- "0 postulates" is not the same claim as "0 hypotheses". `Receipt` is +-- unconditional. `Integrity` is *not*: its theorems are parameterised by a +-- `Hash` record whose `≈d-sound` field assumes a sound digest comparator, so +-- they are conditional on that and say so in their types. That is a named, +-- caller-supplied assumption — not an escape hatch, because --safe is what +-- rules escape hatches out — and it is why R9 is about digests rather than +-- bytes. -- -- Canonical invocation (PROOF-POLICY §2.1): -- agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda diff --git a/meta-checker/src/Echidna/Receipt.agda b/meta-checker/src/Echidna/Receipt.agda index a098979b..551ddcdd 100644 --- a/meta-checker/src/Echidna/Receipt.agda +++ b/meta-checker/src/Echidna/Receipt.agda @@ -1,3 +1,5 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ------------------------------------------------------------------------ -- Receipt vs Warrant -- @@ -15,14 +17,15 @@ -- gap — a discriminating check, not more of the same evidence. -- -- Checked with: agda --safe --without-K --cubical-compatible --- (no postulates, no assumptions; the only hypothesis is the --- discriminating check in the final section, and it is a parameter) +-- (no postulates and no hypotheses: every result here is unconditional, +-- including the sharpened frame, which is a second model rather than an +-- assumption added to the first) ------------------------------------------------------------------------ module Echidna.Receipt where open import Data.Empty using (⊥; ⊥-elim) -open import Data.Product using (Σ-syntax; _×_; _,_) +open import Data.Product using (Σ-syntax; _×_; _,_; proj₁) open import Data.Unit using (⊤; tt) ------------------------------------------------------------------------ @@ -124,20 +127,46 @@ no-receipt-anywhere w-here r = r ≈-cross no-receipt-anywhere w-there r = r ≈-there ------------------------------------------------------------------------ --- Constructive counterpart: a discriminating check yields a receipt +-- The sharpened frame: excluding the alternative is what buys a receipt -- --- The hypothesis is a *parameter*, not a postulate: any caller that --- supplies such a check gets a receipt for free. In ECHIDNA terms this --- is an independently checked certificate (Rocq/Lean re-check, or --- statement comparison across two kernels) — precisely the evidence --- R5 is missing. ------------------------------------------------------------------------- - -module _ (discriminates : w-here ≈ w-there → ⊥) where - - receipt-from-check : Receipt sound w-here - receipt-from-check {w-here} _ = tt - receipt-from-check {w-there} r = ⊥-elim (discriminates r) +-- Crucially this is a *second model*, not a hypothesis bolted onto the +-- first. A function `w-here ≈ w-there → ⊥` cannot exist in the coarse +-- frame — `≈-cross` inhabits its domain — so taking one as a parameter +-- would be vacuous, and would say nothing about what a receipt is. Here +-- the same two worlds keep their identity and only the accessibility +-- changes: the agent can now tell them apart. That is what a +-- discriminating check buys, and unlike the impossible hypothesis it is +-- inhabited. +------------------------------------------------------------------------ + +data _≈′_ : World → World → Set where + ≈′-here : w-here ≈′ w-here + ≈′-there : w-there ≈′ w-there + +≈′-refl : ∀ w → w ≈′ w +≈′-refl w-here = ≈′-here +≈′-refl w-there = ≈′-there + +Receipt′ : Atom → World → Set +Receipt′ φ w = ∀ {w′} → w ≈′ w′ → ⟦ φ ⟧ w′ + +-- A receipt exists in this frame... +receipt-in-sharpened-frame : Receipt′ sound w-here +receipt-in-sharpened-frame {w-here} _ = tt +receipt-in-sharpened-frame {w-there} () + +-- ...and it is still factive, so the token means the same thing in both +-- frames: what changed is the evidence, not the standard of proof. +receipt′-factive : ∀ {φ w} → Receipt′ φ w → ⟦ φ ⟧ w +receipt′-factive r = r (≈′-refl _) + +-- THE CONTRAST, and the point of the whole module: identical worlds, +-- identical proposition, no receipt in the coarse frame and a receipt in +-- the sharpened one. Nothing about the *claim* changed; what changed is +-- whether the alternative world was excluded. +coarse-yet-sharpened : + (Receipt sound w-here → ⊥) × Receipt′ sound w-here +coarse-yet-sharpened = no-receipt-anywhere w-here , receipt-in-sharpened-frame ------------------------------------------------------------------------ -- Token discipline @@ -147,6 +176,15 @@ module _ (discriminates : w-here ≈ w-there → ⊥) where -- is not free. ------------------------------------------------------------------------ +-- The axioms the policy's PROVEN-WITH-AXIOMS token is *about*. Naming one +-- is the entire difference between that token and `proven`: asserting the +-- proposition alone would make the two indistinguishable, which is the +-- accounting §5 exists to keep. +data Axiom : Set where + funext : Axiom + choice : Axiom + em : Axiom + data Verdict : Set where proven : Verdict proven-with-postulates : Verdict @@ -156,11 +194,17 @@ data Verdict : Set where Asserts : Verdict → (φ : Atom) → World → Set Asserts proven φ w = Receipt φ w -Asserts proven-with-postulates φ w = Receipt φ w +Asserts proven-with-postulates φ w = Axiom × Receipt φ w Asserts tested-only φ w = Warrant φ w Asserts argued φ w = Warrant φ w Asserts no-claim φ w = ⊤ +-- A `proven-with-postulates` assertion carries the axiom it rests on; a +-- `proven` assertion carries none, which is why they are different types. +postulates-name-an-axiom : + ∀ {φ w} → Asserts proven-with-postulates φ w → Axiom +postulates-name-an-axiom = proj₁ + -- The same warrant never licences the stronger token. no-free-promotion : ∀ {φ w} → Asserts argued φ w → Asserts proven φ w → ⊥ diff --git a/scripts/install-proof-toolchains.sh b/scripts/install-proof-toolchains.sh index af44667e..a2b60b70 100755 --- a/scripts/install-proof-toolchains.sh +++ b/scripts/install-proof-toolchains.sh @@ -23,10 +23,21 @@ # PYTHONPATH=/tmp/py-tc PATH=/tmp/py-tc/bin:$PATH # mkdir -p /tmp/py-tc/agda/data/lib # wheel ships no primitives # # copy src/data from the v2.7.0.1 tag into /tmp/py-tc/agda/data/ +# # # stdlib: codeload.github.com/agda/agda-stdlib/tar.gz/refs/tags/v2.1 +# # Downloading is not enough — it must be *registered*, or every module that +# # imports Agda.Primitive fails: +# echo "/tmp/agda-stdlib-2.1/standard-library.agda-lib" >> ~/.agda/libraries +# # the tarball's `name:` line is `standard-library-2.1`, and `defaults` (or a +# # .agda-lib `depend:`) must match that name, not the bare `standard-library` +# echo "standard-library-2.1" >> ~/.agda/defaults +# echo "$PWD/meta-checker/echidna-meta.agda-lib" >> ~/.agda/libraries # export LC_ALL=C.UTF-8 # Unicode identifiers # -# Then check with the PROOF-POLICY §2.1 flags: +# Then check with the PROOF-POLICY §2.1 flags. Run it from meta-checker/ — the +# source path is relative to that directory, so from the repo root it does not +# resolve: +# cd meta-checker # agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda # # No Rust, Creusot, Lean 4, Idris 2, Coq, Z3 or cvc5 leg was obtainable that way, From 59e88cdd4b5caf52cf3faadf02b551dc6f31775b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:01:13 +0000 Subject: [PATCH 04/30] Update scripts/install-proof-toolchains.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/install-proof-toolchains.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/install-proof-toolchains.sh b/scripts/install-proof-toolchains.sh index a2b60b70..00362a6e 100755 --- a/scripts/install-proof-toolchains.sh +++ b/scripts/install-proof-toolchains.sh @@ -20,7 +20,8 @@ # compiler's data/lib directory populated by hand because the wheel omits it: # # pip install --target /tmp/py-tc agda # Agda 2.7.0.1 -# PYTHONPATH=/tmp/py-tc PATH=/tmp/py-tc/bin:$PATH +# export PYTHONPATH=/tmp/py-tc +# export PATH=/tmp/py-tc/bin:$PATH # mkdir -p /tmp/py-tc/agda/data/lib # wheel ships no primitives # # copy src/data from the v2.7.0.1 tag into /tmp/py-tc/agda/data/ # From 12833929d07629dd5b71fc1f5509f05cef3c8392 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:10:56 +0000 Subject: [PATCH 05/30] docs(code): document trust clamping, integrity checks, and GraphQL startup --- meta-checker/src/Echidna/ClampTrust.agda | 2 ++ meta-checker/src/Echidna/Integrity.agda | 5 +++-- src/interfaces/graphql/main.rs | 9 +++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/meta-checker/src/Echidna/ClampTrust.agda b/meta-checker/src/Echidna/ClampTrust.agda index 2a954f69..f8b01b52 100644 --- a/meta-checker/src/Echidna/ClampTrust.agda +++ b/meta-checker/src/Echidna/ClampTrust.agda @@ -45,6 +45,8 @@ open import Echidna.TrustLevel using -- The clamp, exactly as the Idris2 model defines it ------------------------------------------------------------------------ +-- Map a numeric trust score to its level: 0 and 1 yield Level1, 2–4 +-- yield the corresponding level, and scores of 5 or more yield Level5. clamp-trust : ℕ → TrustLevel clamp-trust zero = Level1 clamp-trust (suc zero) = Level1 diff --git a/meta-checker/src/Echidna/Integrity.agda b/meta-checker/src/Echidna/Integrity.agda index 971fb670..b14ce9df 100644 --- a/meta-checker/src/Echidna/Integrity.agda +++ b/meta-checker/src/Echidna/Integrity.agda @@ -73,8 +73,9 @@ module _ (H : Hash) where field digest : Digest open Binary public - -- The specified checker. Note the first line: an uninitialised - -- manifest produces `Missing`, never `Verified`. + -- Check a binary against the recorded digest using the supplied comparator. + -- An uninitialised manifest yields `Missing`; otherwise a true comparison + -- yields `Verified` and a false comparison yields `Mismatch`. check : Manifest → Binary → Result check Uninitialised _ = Missing check (Initialised d) b = decide (_≈d_ d (digest b)) diff --git a/src/interfaces/graphql/main.rs b/src/interfaces/graphql/main.rs index 771cce04..29ed0428 100644 --- a/src/interfaces/graphql/main.rs +++ b/src/interfaces/graphql/main.rs @@ -13,6 +13,15 @@ mod schema; use resolvers::EchidnaContext; use schema::{MutationRoot, QueryRoot}; +/// Runs the GraphQL endpoint and playground at `/` and the health check at +/// `/health`, with permissive CORS. The bind address comes from +/// `ECHIDNA_GRAPHQL_ADDR`, defaulting to `127.0.0.1:8081` when it is absent or +/// not valid Unicode. FFI initialisation failure falls back to direct Rust calls. +/// +/// # Panics +/// +/// Panics if logging or the HTTP client cannot be initialised, the address +/// cannot be bound, or the server fails. #[tokio::main] async fn main() { tracing_subscriber::fmt() From 977ce94d9a83b8a3a47f54a67a14d004380fcdfa Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:36:32 +0000 Subject: [PATCH 06/30] fix(ci): qualify proof, audit and live-prover execution Replace repeated Agda and cargo-audit source bootstraps with checksum-verified upstream releases. Audit both lockfiles and update vulnerable dependencies. Reject missing or unselected live prover tests, provision real Chuffed, resolve the explicit Guix package subset, repair canonical reusable callers, and correct proof-model evidence with targeted rejecting controls. Provide an opt-in, read-only official actions.lock refresh candidate for the published workflow changes; canonical lock verification remains required. --- .github/workflows/agda-meta-checker.yml | 89 +++-------- .github/workflows/cargo-audit.yml | 20 +-- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/live-provers.yml | 49 ++++-- .github/workflows/rust-ci.yml | 8 +- .github/workflows/secret-scanner.yml | 2 +- .github/workflows/workflow-linter.yml | 51 ++++++ Cargo.lock | 30 ++-- docs/DEBT.adoc | 32 ++-- docs/PROOF-NEEDS.adoc | 48 ++++-- docs/PROOF-STATUS.adoc | 135 ++++++++-------- .../2026-11-04-agda-meta-checker-receipt.json | 132 ++++++++++++++++ fuzz/Cargo.lock | 116 ++++---------- manifests/live-provers.scm | 43 ++--- meta-checker/src/Echidna/Integrity.agda | 51 +++--- meta-checker/src/Echidna/MetaChecker.agda | 22 ++- meta-checker/src/Echidna/Receipt.agda | 149 ++++++++++++++---- scripts/ci/audit-dependencies.sh | 61 +++++++ scripts/ci/check-agda.sh | 38 +++++ scripts/ci/check-receipt-controls.sh | 97 ++++++++++++ scripts/ci/install-cargo-audit.sh | 25 +++ scripts/ci/run-live-prover.sh | 36 +++++ scripts/ci/setup-agda.sh | 62 ++++++++ scripts/install-proof-toolchains.sh | 5 +- src/rust/provers/chuffed.rs | 15 +- tests/live_prover_suite.rs | 30 +++- 27 files changed, 963 insertions(+), 387 deletions(-) create mode 100644 docs/reports/2026-11-04-agda-meta-checker-receipt.json create mode 100644 scripts/ci/audit-dependencies.sh create mode 100644 scripts/ci/check-agda.sh create mode 100644 scripts/ci/check-receipt-controls.sh create mode 100644 scripts/ci/install-cargo-audit.sh create mode 100644 scripts/ci/run-live-prover.sh create mode 100644 scripts/ci/setup-agda.sh diff --git a/.github/workflows/agda-meta-checker.yml b/.github/workflows/agda-meta-checker.yml index c82ed7a6..d5510daa 100644 --- a/.github/workflows/agda-meta-checker.yml +++ b/.github/workflows/agda-meta-checker.yml @@ -13,11 +13,16 @@ on: branches: [main] paths: - 'meta-checker/**' + - 'scripts/ci/*agda.sh' + - 'scripts/ci/check-receipt-controls.sh' + - '.github/workflows/agda-meta-checker.yml' # No paths-filter on pull_request: this is a REQUIRED status check, so it must # report on every PR (a path-filtered required check deadlocks PRs that don't # touch its paths). The "Detect relevant changes" step path-gates internally. pull_request: branches: [main] + schedule: + - cron: '30 2 * * *' workflow_dispatch: # Cause-B mitigation (#77): cancel superseded runs so stacked pushes @@ -33,11 +38,14 @@ permissions: jobs: verify-proofs: name: Type-check Agda proofs - runs-on: ubuntu-latest - timeout-minutes: 15 + runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false # Required-check shim: only the meta-checker/ tree needs the real proof # check. Non-PR events (push, workflow_dispatch) always run it. @@ -47,7 +55,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - PATTERN='^(meta-checker/|\.github/workflows/agda-meta-checker\.yml$)' + PATTERN='^(meta-checker/|scripts/ci/(setup-agda|check-agda|check-receipt-controls)\.sh$|\.github/workflows/agda-meta-checker\.yml$)' if [ "${{ github.event_name }}" = "pull_request" ]; then FILES=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ --paginate --jq '.[].filename') @@ -56,85 +64,24 @@ jobs: echo "meta-checker/ sources changed — running full Agda type-check." else echo "relevant=false" >> "$GITHUB_OUTPUT" - echo "No meta-checker changes — pass-through (required-check shim)." + echo "NOT APPLICABLE: proof source/tooling paths unchanged; compiler did not run." fi else echo "relevant=true" >> "$GITHUB_OUTPUT" echo "Non-PR event — running full Agda type-check." fi - - name: Setup Haskell - if: steps.detect.outputs.relevant == 'true' - uses: haskell-actions/setup@v2.12.1 - with: - ghc-version: '9.6' - cabal-version: '3.10' - - - name: Cache Agda + - name: Cache pinned Agda archives if: steps.detect.outputs.relevant == 'true' uses: actions/cache@v6.1.0 with: - path: | - ~/.cabal - ~/.agda - key: agda-${{ runner.os }}-${{ hashFiles('meta-checker/**/*.agda') }} + path: ~/.cache/echidna/agda + key: agda-archives-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/ci/setup-agda.sh') }} - - name: Install Agda + - name: Install pinned Agda and standard library if: steps.detect.outputs.relevant == 'true' - run: | - cabal update - # --overwrite-policy=always: the cached ~/.cabal restore already - # carries the agda symlink, so a plain install aborts with - # "Path '.../bin/agda' already exists" on a warm cache. - cabal install Agda-2.7.0.1 --overwrite-policy=always - - - name: Install Agda standard library - if: steps.detect.outputs.relevant == 'true' - run: | - mkdir -p ~/.agda - cd /tmp - wget -q https://github.com/agda/agda-stdlib/archive/refs/tags/v2.1.tar.gz - tar xzf v2.1.tar.gz - echo "/tmp/agda-stdlib-2.1/standard-library.agda-lib" > ~/.agda/libraries - echo "standard-library" > ~/.agda/defaults + run: bash scripts/ci/setup-agda.sh - name: Type-check meta-checker (PROOF-POLICY §2.1 canonical invocation) if: steps.detect.outputs.relevant == 'true' - run: | - cd meta-checker - echo "$PWD/echidna-meta.agda-lib" >> ~/.agda/libraries - # Canonical invocation, PROOF-POLICY §2.1. --safe is what excludes - # postulates, --type-in-type and primTrustMe; it is not a grep. - # NEVER weaken these flags to get a green run: a failing leg is the - # finding. Note --cubical-compatible is NOT --cubical: the - # -W[no]UnsupportedIndexedMatch warnings this emits mean the flagged - # functions do not compute under transports. Recorded as a limit in - # docs/PROOF-STATUS.adoc R4. - agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda - - - name: Assert the checked closure covers every module - if: steps.detect.outputs.relevant == 'true' - run: | - cd meta-checker - # A module that nothing imports gets no interface and is never - # type-checked — AxiomTrackerCompleteness.agda sat outside the - # closure, and did not parse, until 2026-11-01. Check each source - # against *its own* interface rather than comparing totals: a count - # can be offset by a stray interface elsewhere, and a non-recursive - # glob misses nested modules. - # Agda writes interfaces under _build//agda/ when a - # .agda-lib is present, so mirroring the source path is - # version-dependent; match on module basename instead. - ifaces=$(find . -name '*.agdai' -type f | sed 's#.*/##; s#\.agdai$##' | sort -u) - missing=0 - total=0 - while IFS= read -r f; do - total=$((total + 1)) - mod=$(basename "${f%.agda}") - if ! printf '%s\n' "$ifaces" | grep -qx "$mod"; then - echo "::error::$f produced no interface — module is outside the checked closure" - missing=$((missing + 1)) - fi - done < <(find src -name '*.agda' | sort) - echo "${total} source modules, $((total - missing)) with interfaces" - test "$missing" -eq 0 + run: bash scripts/ci/check-agda.sh diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index fed125be..1a2a16bd 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -15,8 +15,8 @@ on: push: branches: [main, master] schedule: - # Run weekly on Monday at 06:00 UTC to catch new advisories. - - cron: '0 6 * * 1' + # Recheck unchanged dependencies daily for newly published advisories. + - cron: '0 6 * * *' # Cause-B mitigation (#77): cancel superseded runs so stacked pushes # to the same ref don't pile up identical jobs in the queue. @@ -43,8 +43,9 @@ jobs: if [ -f Cargo.lock ]; then echo "present=true" >> "$GITHUB_OUTPUT" else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "No Cargo.lock — skipping dependency audit." >> "$GITHUB_STEP_SUMMARY" + echo "::error::Cargo.lock is missing; dependency audit NOT RUN" + echo "Dependency audit NOT RUN: Cargo.lock is missing." >> "$GITHUB_STEP_SUMMARY" + exit 2 fi - name: Install Rust toolchain @@ -55,15 +56,8 @@ jobs: - name: Install cargo-audit if: steps.detect.outputs.present == 'true' - run: cargo install cargo-audit --locked + run: bash scripts/ci/install-cargo-audit.sh - name: Run cargo audit if: steps.detect.outputs.present == 'true' - run: cargo audit - - - name: Write summary - if: always() && steps.detect.outputs.present == 'true' - run: | - echo "## Cargo Audit Results" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - cargo audit 2>&1 | tail -20 >> "$GITHUB_STEP_SUMMARY" || true + run: bash scripts/ci/audit-dependencies.sh diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index a8d0dc85..a18624ab 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -18,4 +18,4 @@ permissions: jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@3c00687a5bbd0d881d351db8502417b5d0a8b0b0 # main 2026-10-20, standards#1315 hypatia-ref aa34a4cc (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + uses: metadatastician/standards/.github/workflows/governance-reusable.yml@d3e4b485a2af90c92a8e18af0888cd19cb132cd9 # published source, 2026-11-04 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 76f80191..52ec9fbc 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -21,6 +21,6 @@ permissions: jobs: hypatia: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@3c00687a5bbd0d881d351db8502417b5d0a8b0b0 # main 2026-10-20, standards#1315 hypatia-ref aa34a4cc (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@d3e4b485a2af90c92a8e18af0888cd19cb132cd9 # published source, 2026-11-04 secrets: HYPATIA_SCAN_PAT: ${{ secrets.HYPATIA_SCAN_PAT }} diff --git a/.github/workflows/live-provers.yml b/.github/workflows/live-provers.yml index 5f66d4bb..5ec8ab32 100644 --- a/.github/workflows/live-provers.yml +++ b/.github/workflows/live-provers.yml @@ -116,37 +116,56 @@ jobs: sudo install -m 0755 /tmp/alt-ergo /usr/local/bin/alt-ergo ;; glpk) sudo apt-get install -y --fix-missing glpk-utils ;; minizinc) sudo apt-get install -y --fix-missing minizinc ;; - chuffed) sudo apt-get install -y --fix-missing minizinc # chuffed bundled with minizinc-ide on recent ubuntu + chuffed) # Ubuntu's minizinc package does not include fzn-chuffed. + # The upstream bundle does; authenticate it before extraction. + bundle="$RUNNER_TEMP/MiniZincIDE-2.10.1-x86_64-linux-gnu.tgz" + curl --fail --location --retry 3 --max-time 120 --output "$bundle" \ + https://github.com/MiniZinc/MiniZincIDE/releases/download/2.10.1/MiniZincIDE-2.10.1-x86_64-linux-gnu.tgz + printf '%s %s\n' b63dd88491d47b05d0eb0ff4aa1112d1e6f77292c023800b20b98851458ce01c "$bundle" | sha256sum --check + tar --no-same-owner -xzf "$bundle" -C "$RUNNER_TEMP" \ + MiniZincIDE-2.10.1-x86_64-linux-gnu/bin/fzn-chuffed + echo "$RUNNER_TEMP/MiniZincIDE-2.10.1-x86_64-linux-gnu/bin" >> "$GITHUB_PATH" ;; esac - "${{ matrix.backend }}" --version 2>/dev/null || "${{ matrix.backend }}" --help 2>/dev/null || true - name: Run live test for ${{ matrix.backend }} - run: cargo test --test live_prover_suite --features live-provers -- --nocapture ${{ matrix.backend }} + env: + LIVE_PROVER: ${{ matrix.backend }} + run: bash scripts/ci/run-live-prover.sh "$LIVE_PROVER" # ============================================================================ - # Tier 1 reproducibility check — ensure Guix manifest actually resolves. - # Allow-fail: this is additional proof, not a gate (Guix-on-Ubuntu is flaky - # without dedicated setup). + # Resolve the explicitly packaged Guix subset. This is not live-prover + # execution evidence and does not cover the externally provisioned backends. # ============================================================================ tier1-guix-reproducibility: name: T1 Guix manifest check - if: github.event_name != 'schedule' - runs-on: ubuntu-latest + if: github.event_name != 'schedule' || github.event.schedule == '0 3 * * *' + # Ubuntu 26.04 removed the guix APT package. Noble has this audited version. + runs-on: ubuntu-24.04 timeout-minutes: 20 - continue-on-error: true # allow-fail: issue=#416 expires=2026-11-30 steps: - name: Checkout uses: actions/checkout@v7.0.1 - name: Install Guix run: | - sudo apt-get update -qq || sudo apt-get update -qq --fix-missing - sudo apt-get install -y --fix-missing guix - - name: Resolve manifest (doesn't run tests — just proves the .scm parses) + set -euo pipefail + trap 'echo "::error::Guix setup failed; manifest check NOT RUN"; exit 2' ERR + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends guix=1.4.0-6build1 + sudo systemctl start guix-daemon + guix --version + - name: Resolve packaged manifest (not a live-prover test) run: | - guix describe || true - guix package -m manifests/live-provers.scm --dry-run || \ - echo "NOTE: Guix manifest resolution failed — non-blocking reproducibility signal." + set -euo pipefail + { + echo '## Guix manifest scope' + echo + echo 'This job resolves the Guix-packaged subset; it does not build or run provers.' + echo 'Guix coverage for cvc5, alt-ergo, vampire, eprover, spass, chuffed, Idris2 and Isabelle is UNKNOWN.' + echo 'Their separate live-prover jobs must supply execution evidence; this job cannot satisfy it.' + echo 'See manifests/live-provers.scm for the package boundary.' + } >> "$GITHUB_STEP_SUMMARY" + guix package --manifest=manifests/live-provers.scm --dry-run --no-substitutes # ============================================================================ # Tier 2 — nightly. Larger build-from-source provers. diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index ec9b65c7..81f5c692 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -203,13 +203,11 @@ jobs: toolchain: stable - name: Install cargo-audit - # Use the binstall path when available to skip a from-source rebuild - # on every run — was the single biggest contributor to slow CI on - # repos that opted in to audit (~3–4 minute install). - run: cargo install cargo-audit --locked + # SHA-256-verified upstream release: no source rebuild on every run. + run: bash scripts/ci/install-cargo-audit.sh - name: Security audit - run: cargo audit + run: bash scripts/ci/audit-dependencies.sh coverage: timeout-minutes: 25 diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index fd1c6e17..567570df 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -23,4 +23,4 @@ jobs: # caller must grant at least that or the run startup-fails. permissions: contents: read - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@3c00687a5bbd0d881d351db8502417b5d0a8b0b0 # main 2026-10-20, standards#1315 hypatia-ref aa34a4cc (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@52a90c24bf183437b887dd182c5a9406af02c5dd # published source, 2026-11-04 diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index f72ffe8d..8f4c7e33 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -6,6 +6,7 @@ name: Workflow Security Linter on: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.github/workflows/**' - 'scripts/ci/**' @@ -15,6 +16,13 @@ on: - '.github/workflows/**' - 'scripts/ci/**' - '.hypatia-ignore' + workflow_dispatch: + inputs: + refresh_actions_lock: + description: Generate an official actions.lock candidate for review + type: boolean + required: false + default: false permissions: read-all @@ -41,3 +49,46 @@ jobs: # gh-actions-lock pin, and it accepts reusable-workflow coverage. The step # that stood here pinned its own v0.1.6 binary, which cannot read # lockfile v0.0.3, so it failed whenever a current tool wrote the lock. + + # Opt-in repair surface, not verification of the committed lock. The + # canonical governance gate above remains authoritative after application. + # No write token, commit, push, or automatic admission is involved. + - name: Prepare official actions.lock refresh candidate + if: >- + always() && + ((github.event_name == 'workflow_dispatch' && inputs.refresh_actions_lock) || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'ci:refresh-actions-lock'))) + env: + GH_TOKEN: ${{ github.token }} + GH_PROMPT_DISABLED: '1' + run: | + set -euo pipefail + for tool in gh jq yq; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "::error::$tool missing; lock refresh NOT RUN" + exit 2 + fi + done + gh extension install github/gh-actions-lock --pin v0.2.0 + gh api 'repos/metadatastician/standards/contents/scripts/update-actions-lock.sh?ref=d3e4b485a2af90c92a8e18af0888cd19cb132cd9' \ + --jq .content | base64 --decode > "$RUNNER_TEMP/update-actions-lock.sh" + bash "$RUNNER_TEMP/update-actions-lock.sh" .github/workflows + patch="$RUNNER_TEMP/actions-lock-refresh.patch" + git diff --binary -- .github/workflows > "$patch" + if [ "$(wc -c < "$patch")" -gt 900000 ]; then + echo "::error::Candidate exceeds the summary budget; lock refresh needs artifact retrieval" + exit 2 + fi + { + echo '## Official actions.lock refresh candidate' + echo + echo "Source checkout: $(git rev-parse HEAD)" + echo 'Uncommitted candidate only. Re-run canonical governance on the committed result.' + echo + echo '````diff' + cat "$patch" + echo '````' + } >> "$GITHUB_STEP_SUMMARY" + cat "$patch" diff --git a/Cargo.lock b/Cargo.lock index c9629440..ae5c0165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,7 +133,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -144,7 +144,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -621,9 +621,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -640,7 +640,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2163,7 +2163,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2634,9 +2634,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.5" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b" dependencies = [ "bitflags", "memchr", @@ -3035,7 +3035,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3408,7 +3408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3572,7 +3572,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3650,9 +3650,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -4392,7 +4392,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/docs/DEBT.adoc b/docs/DEBT.adoc index 82daa8fe..8e4405ed 100644 --- a/docs/DEBT.adoc +++ b/docs/DEBT.adoc @@ -460,21 +460,29 @@ integrity as step 1 of the trust pipeline. The check is conditional (`+dispatch.rs:632+`): with no checker attached, `+solver_integrity_ok` is `true`. `+with_integrity_checker+` has no caller in the tree, and `+config/solver-manifest.toml+` holds `+PLACEHOLDER_COMPUTE_ON_FIRST_RUN+` for -every solver, so attaching one today would report `+Uninitialized+` — which the -code also treats as passing. +every solver. An installed binary with a placeholder digest reports +`+Uninitialized+`, which the dispatch code permits; an absent binary reports +`+Missing+`. Neither outcome is a verified binary identity. Fixed so far: the docs and the published site now say "capability present, not currently engaged" instead of claiming a step that does not run. Now machine-checked on the specification side: `+meta-checker/src/Echidna/Integrity.agda+` (`+docs/PROOF-STATUS.adoc+` R9) -proves that verification requires an initialised manifest and that an -uninitialised manifest never verifies — with collision resistance carried as an -explicit hypothesis, and a sharpness result showing that hypothesis cannot be -dropped. The accepting call sites are `+dispatch.rs+` 642, 848, 998 and -`+solver_integrity.rs+` 359. So the divergence is checked on one side and read on -the other, rather than read on both: closing D9 means changing those four lines, -not the proof. +proves that verification in its model requires an initialised manifest and +that an uninitialised manifest never verifies. Its explicit hypothesis is +digest-comparison soundness: an accepted comparison implies equal digests. +The model has no binary contents or hashing function, so it does not prove +content-level collision resistance and cannot distinguish equal-digest +substitutions. The sharpness theorem directly refutes comparator soundness +for an accept-everything comparator. + +The accepting Rust branches in `+dispatch.rs+` and +`+integrity/solver_integrity.rs+` are established by source comparison. The +Agda model has no dispatch-permission decision and no machine-checked +refinement connecting it to those branches. Closing D9 requires an explicit +runtime policy, correct wiring, non-placeholder manifests and execution tests; +the model theorem alone does not enforce that policy. Open: decide whether to wire it (introducing a refuse-on-mismatch failure mode) or to delete the step from the pipeline description. Recorded rather than @@ -482,8 +490,10 @@ silently changed. [source,bash] ---- -grep -rn 'with_integrity_checker' src/ crates/ tests/ # definition only -grep -n 'PLACEHOLDER' config/solver-manifest.toml +rg -n 'with_integrity_checker|IntegrityStatus::Uninitialized|solver_integrity_ok' src/ crates/ tests/ +rg -n 'PLACEHOLDER' config/solver-manifest.toml +# After the pinned setup in scripts/ci/setup-agda.sh: +bash scripts/ci/check-agda.sh ---- ==== D10. `+--features verisim+` changes verification routing, not just storage diff --git a/docs/PROOF-NEEDS.adoc b/docs/PROOF-NEEDS.adoc index d09809af..9aa00127 100644 --- a/docs/PROOF-NEEDS.adoc +++ b/docs/PROOF-NEEDS.adoc @@ -299,11 +299,16 @@ a future port reviewer does not need to re-audit. === Proof updating, 2026-11-01 -First session in which the Agda corpus was type-checked under the +The 2026-11-01 report described the first session in which the Agda corpus +was type-checked under the `+PROOF-POLICY+` §2.1 canonical invocation -(`--safe --without-K --cubical-compatible`) rather than plain `agda`. Receipt: -exit 0, 98 s cold, 9 modules, 214 stdlib modules, 53 warnings all of one class. -Status ledger: `+docs/PROOF-STATUS.adoc+` R4, R9, R10, R11. +(`--safe --without-K --cubical-compatible`) rather than plain `agda`. +Its cited commit `+916b769+` does not contain the reported nine-module +closure, so those historical timing/count claims are not a reproducible +receipt for the current corpus. The replacement check and exact source +digests are recorded in +link:reports/2026-11-04-agda-meta-checker-receipt.json[`+2026-11-04-agda-meta-checker-receipt.json+`] +and `+docs/PROOF-STATUS.adoc+` R4, R9, R10, R11. ==== What changed @@ -323,16 +328,18 @@ when `+worst-policy+` splits on its **first** argument, so the term was stuck |`+Integrity.agda+` |did not exist; E11 had prose only -|States E11 and proves it: verification requires an initialised manifest, a -substituted binary is never verified, an uninitialised manifest never verifies. -Collision resistance is a record field, and `+sharpness+` shows it cannot be -inhabited for an accept-everything comparator — so the hypothesis is -load-bearing. +|Models E11 over digests: verification requires an initialised manifest, +a binary with a different digest is never verified, and an uninitialised +manifest never verifies. `+≈d-sound+` is digest-comparison soundness: +accepted comparisons imply equal digests. `+sharpness-blocks-soundness+` +refutes that property for an accept-everything comparator. Binary contents, +hashing and content-level collision resistance are outside the model, so +equal-digest substitutions remain indistinguishable. The model does not +verify Rust dispatch permission. |`+ClampTrust.agda+` -|did not exist; the obligations lived only in -`+verification/proofs/idris2/ClampTrustBounds.idr+`, an out-of-policy corpus -with no recorded run +|did not exist; related obligations lived in +`+verification/proofs/idris2/ClampTrustBounds.idr+`, with no recorded run |Bounds, monotonicity, fixed points and idempotence, restated over the trust-level type the gate checks. Monotonicity by step lemma plus transitivity, not the Idris2 file's decision table. @@ -340,8 +347,13 @@ not the Idris2 file's decision table. |`+Receipt.agda+` |did not exist |Receipt/warrant separation as a two-world Kripke countermodel: receipts are -factive, warrants are not, and no warrant promotes to a receipt. Carried over -from `+epistemic-types@5422cd5+` by semantic rewrite; that repo keeps ownership +factive, warrants are not, and there is no uniform warrant-to-receipt +conversion. The coarse frame has no receipt; the sharpened frame has an +inhabited `+proven+` assertion. Conditional assertions require their named +assumption, and bounded tests require a nonempty checked population; neither +may be promoted unconditionally. This compares models and does not prove +that a runtime check changes accessibility. Carried over from +`+epistemic-types@5422cd5+` by semantic rewrite; that repo keeps ownership of its own theorems (`+PROOF-POLICY+` §2.2). |`+MetaChecker.agda+` @@ -353,10 +365,14 @@ plus its `+UnsupportedIndexedMatch+` limit are now recorded in the header. ==== What was not run, and why (PROOF-POLICY §6) -No Rust, Creusot, Lean 4, Idris 2, Rocq, Z3 or cvc5 leg was executed. The -sandbox this was done in could reach only GitHub, PyPI and npm; GitHub *release +The 2026-11-01 report records no Rust, Creusot, Lean 4, Idris 2, Rocq, Z3 or +cvc5 leg. The sandbox used then could reach only GitHub, PyPI and npm; GitHub *release assets* (the only source of the Agda/Lean/Idris2 tarballs) were blocked, and `+scripts/install-proof-toolchains.sh+` needs root, apt and that same host. So R1, R2, R3, R5 and every Stage-8a Idris2 obligation remain exactly as recorded — `+open+` or `+argued+` — and no leg is reported as passing. Reporting them as passing because the flags were "the right ones" would be the failure §6 names. + +The 2026-11-04 repair independently ran pinned Agda and the receipt-model +positive/negative controls. It does not upgrade any of those other prover +legs; see the source-bound receipt and its explicit limitations. diff --git a/docs/PROOF-STATUS.adoc b/docs/PROOF-STATUS.adoc index 3a397719..bcf6c700 100644 --- a/docs/PROOF-STATUS.adoc +++ b/docs/PROOF-STATUS.adoc @@ -3,9 +3,11 @@ = Proof status — policy-vocabulary view -*Status*: current as of 2026-11-01, commit `+916b769+`. +*Status*: reviewed 2026-11-04. Each row is scoped to its recorded source +identity and check; this file does not claim a single revision was checked +for every obligation. *Normative policy*: `+3-practice/PROOF-POLICY.adoc+` in -https://github.com/hyperpolymath/standards[hyperpolymath/standards] (owner +https://github.com/metadatastician/standards[metadatastician/standards] (owner ruling 2026-10-26). This is ECHIDNA's *proof-status file* in the sense `+PROOF-POLICY+` uses the @@ -157,67 +159,67 @@ Mathlib-vs-core, which is an owner call. axiom-tracker completeness, integrity, clamp bounds, receipt/warrant (9 modules) |`+proven+` |First-party Agda in `+meta-checker/src/+`, type-checked with the §2.1 -canonical invocation. Receipt taken 2026-11-01 at commit `+916b769+`: +canonical invocation. The reproducible local receipt is +link:reports/2026-11-04-agda-meta-checker-receipt.json[`+2026-11-04-agda-meta-checker-receipt.json+`]. +It records base commit `+9dd7847dfb095f0ddf3d41bac3327fcc4486fb6b+` plus +the exact SHA-256 of every checked source and library descriptor, and names +the three source files modified in that working tree. It is not a claim +that the unmodified base commit contains these repairs. ---- -agda --safe --without-K --cubical-compatible \ - src/Echidna/MetaChecker.agda # exit 0, 98 s cold +agda --no-default-libraries --library-file "$AGDA_LIBRARIES_FILE" \ + --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda ---- -Versions: Agda `+2.7.0.1+`, `+agda-stdlib+` `+2.1+` (214 stdlib modules pulled -in). All 9 modules in `+src/Echidna/+` produce an interface; 53 warnings, all -of them `+-W[no]UnsupportedIndexedMatch+`, at five sites (`+TrustLevel+` 77, -88, 235; `+AxiomSafety+` 119; `+ClampTrust+` 98). No `+postulate+`, no -`+sorry+`, no `+believe_me+`, no `+Admitted+`; `+--safe+` is what excludes -them rather than a `+grep+`. +Run from `+meta-checker/+`; the explicit library file lists the pinned +standard library and `+meta-checker/echidna-meta.agda-lib+`. Versions: +Agda `+2.7.0.1+`, `+agda-stdlib+` `+2.1+`; archive digests are in the receipt. +Observed exit 0, all 9 full module paths produced fresh interfaces. +The positive receipt/test witnesses and three rejecting verdict controls +also passed. The log contained 55 warnings, all +`+-W[no]UnsupportedIndexedMatch+`, at six sites in `+TrustLevel+`, +`+AxiomSafety+`, `+ClampTrust+` and `+Receipt+`. `+--safe+` enforces the +absence of proof escape hatches; a text search alone is not the receipt. |Trust boundary, named: the Agda 2.7.0.1 kernel under `+--safe+`, plus the stdlib. **Limit, recorded not hidden:** those warnings mean the flagged functions do not *compute* under Cubical transports. The corpus is `+--cubical-compatible+`, not `+--cubical+` — exactly the distinction `+PROOF-POLICY+` §2.1 draws — and dropping the warning, or the flag, to get a -clean log would be the weakening §2.1 forbids. It is immaterial today because -nothing transports these terms; transport is R5's job, which is where the -limit would first bite. - -**Why not just use `+--cubical+`?** Because it fails, and not on this corpus. -`+agda --cubical --safe src/Echidna/MetaChecker.agda+` exits non-zero in -`+agda-stdlib-2.1/src/Data/List/Membership/Setoid/Properties.agda+` with -unsolved metas, before any ECHIDNA module is reached: stdlib 2.1 is written to -be `+--cubical-compatible+`, and ships -`+--warning=noUnsupportedIndexedMatch+` in `+standard-library.agda-lib+` for -precisely this reason. So `+--cubical-compatible+` is not a weakened choice here; -it is the strongest flag this dependency set supports, and the policy's own -distinction between the two flags is what lets that be said plainly. - -Promoted from `+argued+` on the strength of that receipt. Before it, the row -rested on "the CI gate exists and the authors say it passes" — which §5 defines -as `+argued+`, not a receipt. One module -(`+AxiomTrackerCompleteness.agda+`) turned out never to have been in the -checked closure at all, and did not type-check (see R9's note on scope). +clean log would weaken the profile. Full Cubical and the independent Rocq +and Lean legs were not run for this receipt. Changes to any recorded source +invalidate its use for that changed source until a fresh check is recorded. + +**Correction to the old receipt:** `+916b769+` predates `+Integrity+`, +`+ClampTrust+` and `+Receipt+`, and its entrypoint omitted +`+AxiomTrackerCompleteness+`. It cannot substantiate a nine-module claim. +The earlier 2026-11-01 timing/count report is historical, not the evidence +for the current `+proven+` row; the source-bound 2026-11-04 receipt replaces it. |R9. E11 solver-integrity soundness |`+proven+` |`+meta-checker/src/Echidna/Integrity.agda+`, inside the R4 receipt. States the obligation that had no Agda record: verification requires an initialised -manifest; verification means the recorded digest was matched; a substituted -binary is never verified; an uninitialised manifest never verifies. Collision -resistance is a record *field*, not a postulate, so the guarantee is -conditional and says so in its type; `+sharpness+` exhibits a comparator for -which that field cannot be inhabited, so the hypothesis is load-bearing rather -than decorative. +manifest; verification means the recorded digest was matched; a binary with +a different digest is never verified; an uninitialised manifest never +verifies. Digest-comparison soundness is a record *field*, not a postulate, +so the matching theorem is explicitly conditional on that field. +`+sharpness-blocks-soundness+` refutes the exact soundness property for an +accept-everything comparator. **Scope of the model (from review, 2026-11-01):** a binary is modelled by its digest alone, so these are statements about *digests*, not bytes. "Substituted" means "a binary whose digest differs", and two binaries colliding -on a digest are not distinguished by this model. Content-level collision -resistance sits in the `≈d-sound` field of the comparator — assumed, not -established. That is what makes the sharpness result the load-bearing part. - -|**Specification, not implementation.** `+src/rust/dispatch.rs+` lines 642, 848 -and 998, and `+src/rust/integrity/solver_integrity.rs+` line 359, all accept -`+IntegrityStatus::Uninitialized+` as passing. The theorem proves the opposite -of what that code does. That divergence is DEBT D9, now machine-checked on one -side instead of argued on both. Closing R9's implementation leg means changing -the Rust, not the proof. +on a digest are not distinguished by this model. `+≈d-sound+` says that an +accepted comparison implies equal digests; the model contains neither +binary contents nor a hashing function. Content-level collision resistance +is outside this model, not a property established or assumed by that field. + +|**Specification, not implementation.** Rust returns +`+IntegrityStatus::Uninitialized+` for an installed binary with a placeholder +manifest, and dispatch can permit that status. It does not report +`+Verified+` in that case. Agda proves a property of its `+check+` function +and has no dispatch-permission decision. DEBT D9 is therefore a source +comparison, not a machine-checked discrepancy or refinement proof. The Rust +implementation leg remains open under R1's Creusot obligation. |R10. ClampTrustBounds |`+proven+` @@ -226,32 +228,33 @@ the four obligations of `+verification/proofs/idris2/ClampTrustBounds.idr+` (bounds, monotonicity, fixed points, and — added — idempotence, i.e. that the clamp lands on a real level rather than between two) over the trust-level type the gate actually checks. -|The Idris2 original stays *out of policy* per §3 and, as far as this -repository can tell, was never run: `+idris2-abi-ci.yml+` cites it but no -receipt is recorded. This row does not validate that file; it discharges the -same obligations in a corpus that is checked. Monotonicity is by step lemma -plus transitivity rather than the Idris2 file's 36-clause decision table. +|This row establishes finite-order algebra in the Agda model. It does not +validate the Idris2 file or its correspondence to runtime code. Existing +Idris2 interface and engineering checks remain active under §1; their +receipt status is R2. Monotonicity here is by step lemma plus transitivity, +rather than the Idris2 file's decision table. |R11. Epistemic separation: receipt vs warrant |`+proven+` |`+meta-checker/src/Echidna/Receipt.agda+`, inside the R4 receipt. A two-world -Kripke countermodel: a receipt is factive, a warrant is not; the same warrant -never licences the stronger token; while the agent cannot discriminate the -worlds, no receipt exists at all; and a second frame — same worlds, only the -accessibility changed — has a receipt, so what buys one is *excluding the -alternative*, not accumulating more evidence. (An earlier draft took the -discriminating check as a parameter; that was vacuous, since `≈-cross` -inhabits the domain of the function required. Two frames say it properly.) -This is what makes §5's "`+argued+` is not `+proven+`" a theorem instead of a -style rule. +Kripke countermodel: a receipt is factive, a warrant is not, and no uniform +function can promote every warrant to a receipt. No receipt for `+sound+` +exists in the coarse frame. The sharpened frame has an inhabited +`+Asserts assumptions sharpened proven sound w-here+` witness, so the +stronger token is not vacuously impossible. Warrant and receipt can coexist +when both are justified. `+proven-with-postulates+` records a named, +explicitly interpreted assumption and a function from it to a receipt; +discharging that assumption is required. `+tested-only+` has a distinct +nonempty sample population with a checked result per sample. The model +refutes unconditional promotions from conditional and bounded evidence. |Semantic rewrite of the receipt/warrant distinction from https://github.com/hyperpolymath/epistemic-types[epistemic-types] at -`+5422cd5+`, **not** a port of its theorems. For the record, that source is -itself green under the same invocation (22/22 modules, exit 0, 0 postulates), so -this row is a rewrite of something that checks, not of an aspiration: per §2.2 the source project keeps -ownership of those, and this module proves only what ECHIDNA needs. The -residual limit is the honest one — a receipt requires a check that -*discriminates*, which is precisely the evidence R5 does not yet have. +`+5422cd5+`, **not** a port or a receipt for that project's theorems. The +source project retains its own proof ownership under §2.2. This model +compares two frames; it implements no discriminating checker, transition, +external test attestation or runtime-evidence adapter. Justifying that a +real check permits the sharper frame is a separate open obligation. The +Rocq/Lean rechecks and statement correspondence also remain open in R5. |R5. Independent recheck of R4 in Rocq *and* Lean |`+open+` diff --git a/docs/reports/2026-11-04-agda-meta-checker-receipt.json b/docs/reports/2026-11-04-agda-meta-checker-receipt.json new file mode 100644 index 00000000..46784c16 --- /dev/null +++ b/docs/reports/2026-11-04-agda-meta-checker-receipt.json @@ -0,0 +1,132 @@ +{ + "schema_version": 1, + "spdx_license_id": "MPL-2.0", + "kind": "local-agda-model-check", + "recorded_at_utc": "2026-11-04T17:26:43Z", + "source": { + "repository": "https://github.com/hyperpolymath/echidna", + "base_commit": "9dd7847dfb095f0ddf3d41bac3327fcc4486fb6b", + "identity": "base commit plus the exact checked file digests below; not the unmodified base commit", + "modified_source_files": [ + "meta-checker/src/Echidna/Integrity.agda", + "meta-checker/src/Echidna/MetaChecker.agda", + "meta-checker/src/Echidna/Receipt.agda" + ], + "files": [ + { + "sha256": "e8bce1e39b54b5d2a122e971a9c550c48d11e2a950b788027e6ccf2ee213e407", + "path": "meta-checker/echidna-meta.agda-lib" + }, + { + "sha256": "0789afd299c326905be7575bc7e0a207af5bd3a18aa6725f827793d1b3b0f07a", + "path": "meta-checker/src/Echidna/AxiomSafety.agda" + }, + { + "sha256": "a43cbaeba44d73b96061e104c5e063ec64e052e61a3ef4cb58af42b1fc73326a", + "path": "meta-checker/src/Echidna/AxiomTrackerCompleteness.agda" + }, + { + "sha256": "b13a4814e9992b625770756c602ac4783451a2d268ad0774684fcbe27c640f4e", + "path": "meta-checker/src/Echidna/ClampTrust.agda" + }, + { + "sha256": "3891f7f8dc0fd8b70149690aa9818685dd147c2a4aefc63072f7be6f2fecf7d9", + "path": "meta-checker/src/Echidna/Dispatch.agda" + }, + { + "sha256": "5ab8b7ef7e6fbc93a9c7d594647463c5f4aa31c9636d80edfef812a455614844", + "path": "meta-checker/src/Echidna/Integrity.agda" + }, + { + "sha256": "d13c846a233390d3b00bdd8c2f4ba8775e60989808dc114ffd74988f41f249ed", + "path": "meta-checker/src/Echidna/MetaChecker.agda" + }, + { + "sha256": "3a6f43fc49d10c1b63f3e3849ca1fc99b209f5ae4d697c8123ad78d82a0b1e1e", + "path": "meta-checker/src/Echidna/Portfolio.agda" + }, + { + "sha256": "6741bae9360b0b3064ef67b073c06f2831775b70eeb01e1d6e998bc74f02c6e2", + "path": "meta-checker/src/Echidna/Receipt.agda" + }, + { + "sha256": "dab086fcbe17a0611e5679716a36f8db2d93fbe9b63472f0b2caecd50316fa62", + "path": "meta-checker/src/Echidna/TrustLevel.agda" + } + ] + }, + "toolchain": { + "agda": { + "version": "2.7.0.1", + "release_url": "https://github.com/agda/agda/releases/download/v2.7.0.1/Agda-v2.7.0.1-linux.tar.xz", + "archive_sha256": "975b515a14877ab6bce5bfc641e16c4a3ee52ca5d7f846577dee80aa2145db8a", + "executable_sha256": "6de41437c14c05c9fe576d724cc67386444c0fa9fee662a08250fefb91bee8b6" + }, + "standard_library": { + "version": "2.1", + "archive_url": "https://github.com/agda/agda-stdlib/archive/refs/tags/v2.1.tar.gz", + "archive_sha256": "72ca3ea25094efa0439e106f0d949330414232ec4cc5c3c3316e7e70dd06d431" + } + }, + "invocation": { + "working_directory": "meta-checker", + "argv": [ + "agda", + "--no-default-libraries", + "--library-file", + "$AGDA_LIBRARIES_FILE", + "--safe", + "--without-K", + "--cubical-compatible", + "src/Echidna/MetaChecker.agda" + ], + "library_file_entries": [ + "$AGDA_STDLIB_DIR/standard-library.agda-lib", + "$REPOSITORY/meta-checker/echidna-meta.agda-lib" + ], + "Agda_datadir": "$AGDA_DISTRIBUTION/data", + "LC_ALL": "C.UTF-8" + }, + "result": { + "exit_code": 0, + "source_module_count": 9, + "fresh_interface_count": 9, + "interface_identity": "_build/2.7.0.1/agda/", + "warning_count": 55, + "warning_classes": [ + "UnsupportedIndexedMatch" + ], + "warning_sites": [ + "Echidna.TrustLevel:77", + "Echidna.TrustLevel:88", + "Echidna.TrustLevel:235", + "Echidna.AxiomSafety:119", + "Echidna.ClampTrust:103", + "Echidna.Receipt:225" + ], + "log_sha256": "946d37af6692390c459c80ed973276e40a1f62ccbfa49b3d040828da24427b83" + }, + "controls": { + "script": "scripts/ci/check-receipt-controls.sh", + "script_sha256": "a2f0c7e1a040be2ded1fabd1e6604781a0f28f3336e6a8f6d85cf7a4242acd84", + "exit_code": 0, + "accepted": [ + "sharpened frame proven witness", + "nonempty bounded sample witness" + ], + "rejected": [ + "project conditional assumption function into unconditional receipt", + "use a warrant as tested-only evidence", + "claim a false-world sample passed" + ], + "log_sha256": "984a1ad325690ec533bc7ad2f2b84ecda4ddd2ef51a275d914102666c8a91b8a" + }, + "limitations": [ + "A local observation, not a hosted CI attestation or a proof about an arbitrary later revision.", + "Only the listed Agda model sources were checked; no Rust/Creusot, Idris2, Rocq or Lean leg is discharged.", + "UnsupportedIndexedMatch warnings retain the recorded Cubical-transport computation limitation; full Cubical was not run.", + "Receipt compares two frames and models evidence; no runtime checker, frame transition or external test-evidence adapter is verified.", + "Integrity compares digests, not binary contents; no content-level collision resistance or Rust dispatch refinement is established.", + "Named assumptions remain conditional; no external assumption is supplied by this check." + ] +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 42d8ce34..c0cbe0bd 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -112,7 +112,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -123,7 +123,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -408,9 +408,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -427,7 +427,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -626,7 +626,7 @@ dependencies = [ "num-bigint 0.5.1", "num-integer", "num-traits", - "rand 0.10.2", + "rand", "reqwest", "rustfft", "rustyline", @@ -726,7 +726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -886,11 +886,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -900,11 +898,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1459,7 +1459,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1592,15 +1592,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1651,14 +1642,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -1715,16 +1707,6 @@ dependencies = [ "nibble_vec", ] -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -1736,16 +1718,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1757,18 +1729,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.10.1" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "rawpointer" @@ -1893,14 +1865,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -1922,9 +1894,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -2164,7 +2136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2263,7 +2235,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2331,9 +2303,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -2801,9 +2773,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -3162,26 +3134,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.7" diff --git a/manifests/live-provers.scm b/manifests/live-provers.scm index e8a59d82..51d198cc 100644 --- a/manifests/live-provers.scm +++ b/manifests/live-provers.scm @@ -3,10 +3,16 @@ ;; ;; ECHIDNA — Live-Prover CI Manifest (Guix, PRIMARY) ;; -;; Purpose: declare every prover binary that live-prover CI exercises. +;; Purpose: resolve the subset packaged by GNU Guix 1.4.0 (the CI baseline). +;; This manifest is NOT a declaration that every live prover is packaged or +;; tested. Resolution does not prove that a prover ran or that a proof passed. ;; Used by `.github/workflows/live-provers.yml` and local developers via: ;; -;; guix shell -m manifests/live-provers.scm -- cargo test --test live_prover_suite --features live-provers +;; guix package -m manifests/live-provers.scm --dry-run --no-substitutes +;; +;; The release's Rust package is not the repository's pinned Rust toolchain. +;; Building the live test harness also requires rust-toolchain.toml and its +;; native dependencies; manifest resolution alone does not establish that. ;; ;; Tiering (matches ~/Desktop/ECHIDNA-PRODUCTION-WIRING-PLAN.md): ;; T1 — trivial (apt/Guix single-package) — run on every PR @@ -22,29 +28,32 @@ ;; SEALED CONTAINER (.containerization/Containerfile.wave3), not a Nix twin. ;; ;; Availability note: -;; Guix package availability drifts. The comment beside each line records the -;; Guix module the package lives in at audit time. Anything previously marked +;; Package names below were checked against the GNU Guix 1.4.0 release. +;; The comment beside each line records its upstream module. Anything marked ;; NIX_ONLY or CONTAINER_ONLY is *not* in Guix upstream and is provisioned via ;; the Wave-3 sealed container (Containerfile.wave3 --target ). The ;; "NIX_ONLY" label is retained below only as a historical availability fact ;; (the package is absent from Guix) — it does NOT imply a Nix provisioning -;; path is permitted; the provisioning path is the container. +;; path is permitted; the intended provisioning path is the container. +;; +;; Explicit boundary (Guix coverage UNKNOWN, not passed by this manifest): +;; T1 cvc5, alt-ergo, vampire, eprover, spass and chuffed are provisioned by +;; the separate live-prover matrix. cvc4 is not an equivalent cvc5 check. +;; T2 Idris2 and Isabelle are also absent from this baseline. Their separate +;; jobs must provide execution evidence; this resolution job cannot. +;; Chuffed appears in Guix 1.5.0; the other names remain absent from that +;; release inventory. CI currently checks the explicit 1.4.0 baseline. (specifications->manifest '(;; ---------------------------------------------------------------------- - ;; Tier 1 — trivial: ships directly in Guix, every PR runs these live + ;; Tier 1 — packaged subset; live execution is checked by separate jobs ;; ---------------------------------------------------------------------- "z3" ; (gnu packages maths) — SMT, deep wiring - "cvc4" ; (gnu packages maths) — SMT, cvc5 not yet upstream; cvc4 is a usable stand-in - "alt-ergo" ; (gnu packages ocaml) — SMT - "vampire" ; (gnu packages maths) — first-order ATP - "eprover" ; (gnu packages maths) — first-order ATP - "spass" ; (gnu packages maths) — first-order ATP "glpk" ; (gnu packages maths) — LP/MIP constraint solver (glpsol) "minizinc" ; (gnu packages maths) — constraint modelling ;; --- Harness prerequisites - "coreutils" ; which/timeout/etc. used by the harness - "util-linux" ; timeout(1) on GNU systems + "coreutils" ; timeout(1) and other GNU core utilities + "util-linux" ; Linux system utilities used by the harness "gcc-toolchain" ; linker for compiled provers that invoke backends ;; ---------------------------------------------------------------------- @@ -52,9 +61,7 @@ ;; ---------------------------------------------------------------------- "coq" ; (gnu packages coq) — Coq/Rocq "agda" ; (gnu packages agda) — dependent-type proof assistant - "idris2" ; (gnu packages idris) — dependent-type proof assistant - "isabelle" ; (gnu packages isabelle) — HOL proof assistant - "why3" ; (gnu packages ocaml) — auto-active verifier + "why3" ; (gnu packages maths) — auto-active verifier ;; Lean 4 — NIX_ONLY: not in Guix; install via the Wave-3 sealed container ;; (the upstream installer ships pre-built binaries; the historical "flake.nix" ;; path was closed 2026-06-01 per estate-wide nix-deprecation). @@ -85,9 +92,9 @@ ;; NuSMV, Spin, TLC, CBMC, Seahorn, dReal, Boogie, Kissat, Alloy ;; ---------------------------------------------------------------------- - ;; Build toolchain (so Rust test harness compiles inside `guix shell`) + ;; Packaged build tools (not the repository's pinned Rust toolchain) ;; ---------------------------------------------------------------------- "rust" - "rust-cargo" + "rust:cargo" ; cargo output of rust, not the rust-cargo library crate "pkg-config" "openssl")) diff --git a/meta-checker/src/Echidna/Integrity.agda b/meta-checker/src/Echidna/Integrity.agda index b14ce9df..d724e96d 100644 --- a/meta-checker/src/Echidna/Integrity.agda +++ b/meta-checker/src/Echidna/Integrity.agda @@ -6,22 +6,23 @@ -- The current repository has an informal note for E11 and no Agda -- record. This module states the invariant that the integrity checker -- is *supposed* to guarantee, and then proves the sharpness result --- that makes the collision-resistance assumption load-bearing rather +-- that makes the digest-comparison soundness hypothesis load-bearing rather -- than decorative. -- --- The headline theorem is the one the Rust implementation violates: --- an UNINITIALISED manifest must never yield `Verified`. +-- The model's headline theorem says an UNINITIALISED manifest never +-- yields `Verified`. -- src/rust/dispatch.rs:642,848,998 and -- src/rust/integrity/solver_integrity.rs:359 all accept --- `IntegrityStatus::Uninitialized` as passing. The proof below is about --- the specification, not about that code path — which is exactly why it --- is worth having a record: it makes the deviation a machine-checked --- discrepancy (DEBT D9) instead of a reading of the source. +-- `IntegrityStatus::Uninitialized` as permitting dispatch. Rust does not +-- report `Verified` in that case. The model has no dispatch-permission +-- decision: DEBT D9 is a source comparison with this specification, not +-- a machine-checked refinement theorem about the Rust implementation. -- -- Scope: a binary is modelled by its digest only, so "substituted" below --- means "a binary whose digest differs". Content-level collision resistance is --- the `≈d-sound` field of the comparator, i.e. assumed by the caller and not --- established here; `sharpness` shows what happens when it fails. +-- means "a binary whose digest differs". `≈d-sound` requires an accepted +-- comparison to imply equal digests. There are no binary contents or +-- hashing function in the model, so content-level collision resistance +-- is outside its scope. Equal-digest substitutions remain indistinguishable. -- -- Checked with: agda --safe --without-K --cubical-compatible ------------------------------------------------------------------------ @@ -36,7 +37,7 @@ open import Relation.Binary.PropositionalEquality using (_≡_; refl; sym) open import Relation.Nullary using (¬_) ------------------------------------------------------------------------ --- Collision resistance, carried as a record field +-- Digest-comparison soundness, carried as a record field -- -- This is deliberately an *assumption recorded in a structure* rather -- than a postulate: the theorems below are parameterised by it, so the @@ -103,14 +104,14 @@ module _ (H : Hash) where ∀ {d b} → check (Initialised d) b ≡ Verified → d ≡ digest b verified-means-match {d} {b} p = ≈d-sound (decide-verified p) - -- E11c. A substituted binary is never verified. + -- E11c. A binary with a different digest is never verified. substituted-never-verified : ∀ {d b} → ¬ (digest b ≡ d) → ¬ (check (Initialised d) b ≡ Verified) substituted-never-verified {d} {b} neq p = neq (sym (verified-means-match {d} {b} p)) - -- E11d. THE INVARIANT THE IMPLEMENTATION BREAKS (DEBT D9): - -- an uninitialised manifest never yields `Verified`. + -- E11d. An uninitialised manifest never yields `Verified` in this model. + -- Dispatch permission in Rust is a separate, unverified property (D9). uninitialised-never-verified : ∀ b → ¬ (check Uninitialised b ≡ Verified) uninitialised-never-verified b () @@ -118,10 +119,10 @@ module _ (H : Hash) where ------------------------------------------------------------------------ -- Sharpness: the assumption is load-bearing -- --- Without `≈d-sound`, verification does not imply anything about the --- binary. This is proved by exhibiting a comparator that accepts --- everything, under which a manifest for `true` verifies a binary whose --- digest is `false`. +-- An accept-everything comparator accepts distinct digests. Its +-- soundness property is explicitly refuted below; therefore it cannot +-- supply the `≈d-sound` field required by `Hash` and E11b. No `check` +-- function over UnsoundHash, or binary-content theorem, is claimed here. ------------------------------------------------------------------------ record UnsoundHash : Set₁ where @@ -129,7 +130,7 @@ record UnsoundHash : Set₁ where Digest : Set _≈d_ : Digest → Digest → Bool --- A comparator that accepts everything: collision resistance fails. +-- A comparator that accepts everything: digest-comparison soundness fails. always-true : UnsoundHash UnsoundHash.Digest always-true = Bool UnsoundHash._≈d_ always-true _ _ = true @@ -142,10 +143,10 @@ sharpness = refl sharpness-matters : ¬ (true ≡ false) sharpness-matters () --- Hence this comparator satisfies no soundness condition at all: the only --- candidate witness forces `true ≡ false`. So E11b cannot be discharged for --- it, and the `≈d-sound` field in `Hash` is doing real work rather than --- decorating the record. +-- Refute precisely the property required by Hash, not merely Bool +-- inequality: any proposed soundness witness makes true equal false. sharpness-blocks-soundness : - UnsoundHash._≈d_ always-true true false ≡ true → ¬ (true ≡ false) -sharpness-blocks-soundness _ = sharpness-matters + ¬ (∀ {d e : Bool} → + UnsoundHash._≈d_ always-true d e ≡ true → d ≡ e) +sharpness-blocks-soundness sound-comparison = + sharpness-matters (sound-comparison {true} {false} refl) diff --git a/meta-checker/src/Echidna/MetaChecker.agda b/meta-checker/src/Echidna/MetaChecker.agda index 59a33016..2fff5bb5 100644 --- a/meta-checker/src/Echidna/MetaChecker.agda +++ b/meta-checker/src/Echidna/MetaChecker.agda @@ -45,15 +45,20 @@ -- -- FROM Receipt (epistemic-types semantic rewrite): -- ✓ A receipt is factive; a warrant is not --- ✓ A warrant never promotes to a receipt ("no free promotion") +-- ✓ No uniform warrant-to-receipt conversion exists -- ✓ No receipt exists while the agent cannot discriminate the worlds --- ✓ A discriminating check yields a receipt +-- ✓ The second, sharpened frame has an inhabited proven assertion +-- ✓ Named assumptions must be discharged before conditional receipts +-- ✓ Bounded test evidence is distinct from a warrant or universal proof +-- These are model results; no runtime check or frame transition is proved. -- -- FROM Integrity (E11 semantic rewrite): -- ✓ Verification requires an initialised manifest -- ✓ Verification means the recorded digest was matched --- ✓ An uninitialised manifest NEVER verifies (the invariant DEBT D9 breaks) --- ✓ The collision-resistance hypothesis is load-bearing (sharpness) +-- ✓ An uninitialised manifest never verifies in the model +-- ✓ Digest-comparison soundness is load-bearing (sharpness) +-- Rust dispatch permission and content-level collision resistance are outside +-- this model; DEBT D9 records a source comparison, not a refinement proof. -- -- FROM ClampTrust (ClampTrustBounds semantic rewrite): -- ✓ The clamp stays within [1,5] @@ -64,8 +69,9 @@ -- TOTAL: 45+ proven properties, 0 postulates, 0 sorry, 0 believe_me, -- 0 Admitted. -- --- "0 postulates" is not the same claim as "0 hypotheses". `Receipt` is --- unconditional. `Integrity` is *not*: its theorems are parameterised by a +-- "0 postulates" is not the same claim as "0 hypotheses". Receipt's token +-- interpretation explicitly carries named assumptions when requested; +-- its model theorems do not admit external evidence. Integrity takes a -- `Hash` record whose `≈d-sound` field assumes a sound digest comparator, so -- they are conditional on that and say so in their types. That is a named, -- caller-supplied assumption — not an escape hatch, because --safe is what @@ -121,12 +127,12 @@ key-order-2 = ≤ₜ-antisym key-order-3 = ≤ₜ-trans key-order-4 = ≤ₜ-total --- 8. Epistemic separation: a warrant is never a receipt +-- 8. Epistemic separation: no uniform warrant-to-receipt conversion key-epistemic-1 = receipt-factive key-epistemic-2 = warrant-not-factive key-epistemic-3 = no-free-promotion --- 9. Integrity: an uninitialised manifest never verifies (DEBT D9) +-- 9. Integrity: an uninitialised manifest never verifies in the model key-integrity-1 = uninitialised-never-verified key-integrity-2 = verified-means-match diff --git a/meta-checker/src/Echidna/Receipt.agda b/meta-checker/src/Echidna/Receipt.agda index 551ddcdd..be05694a 100644 --- a/meta-checker/src/Echidna/Receipt.agda +++ b/meta-checker/src/Echidna/Receipt.agda @@ -13,20 +13,25 @@ -- -- The argument is a two-world Kripke countermodel: an agent holding a -- warrant cannot distinguish the world in which the verdict is sound --- from one in which it is not. A receipt is exactly what closes that --- gap — a discriminating check, not more of the same evidence. +-- from one in which it is not. A second, sharpened frame admits a +-- receipt. This is a comparison of models: no runtime check or +-- transition between those frames is implemented or verified here. -- -- Checked with: agda --safe --without-K --cubical-compatible --- (no postulates and no hypotheses: every result here is unconditional, --- including the sharpened frame, which is a second model rather than an --- assumption added to the first) +-- No postulates. The token interpretation takes an explicit family of +-- named assumptions; a conditional receipt requires the corresponding +-- assumption before it can be discharged. The theorems below prove +-- properties of this model, not the admission of external evidence. ------------------------------------------------------------------------ module Echidna.Receipt where open import Data.Empty using (⊥; ⊥-elim) -open import Data.Product using (Σ-syntax; _×_; _,_; proj₁) +open import Data.Nat using (ℕ; zero; suc) +open import Data.Product using (Σ-syntax; _×_; _,_; proj₁; proj₂) open import Data.Unit using (⊤; tt) +open import Data.Vec using (Vec; []; _∷_) +open import Data.Vec.Relation.Unary.All using (All; []; _∷_) ------------------------------------------------------------------------ -- Kripke frame @@ -121,7 +126,7 @@ warrant-not-receipt _ r = r ≈-there -- Stronger: because the agent cannot exclude `w-there`, no receipt for -- `sound` exists at *either* world. Accumulating more evidence of the --- same kind cannot produce one; only a check that discriminates can. +-- same kind cannot produce one within this frame. no-receipt-anywhere : ∀ w → Receipt sound w → ⊥ no-receipt-anywhere w-here r = r ≈-cross no-receipt-anywhere w-there r = r ≈-there @@ -134,9 +139,9 @@ no-receipt-anywhere w-there r = r ≈-there -- frame — `≈-cross` inhabits its domain — so taking one as a parameter -- would be vacuous, and would say nothing about what a receipt is. Here -- the same two worlds keep their identity and only the accessibility --- changes: the agent can now tell them apart. That is what a --- discriminating check buys, and unlike the impossible hypothesis it is --- inhabited. +-- changes: the agent can now tell them apart. The receipt is inhabited +-- in this frame; proving that a real check justifies this change is a +-- separate obligation outside this model. ------------------------------------------------------------------------ data _≈′_ : World → World → Set where @@ -156,7 +161,7 @@ receipt-in-sharpened-frame {w-here} _ = tt receipt-in-sharpened-frame {w-there} () -- ...and it is still factive, so the token means the same thing in both --- frames: what changed is the evidence, not the standard of proof. +-- frames: accessibility changed, while factivity still holds. receipt′-factive : ∀ {φ w} → Receipt′ φ w → ⟦ φ ⟧ w receipt′-factive r = r (≈′-refl _) @@ -176,10 +181,53 @@ coarse-yet-sharpened = no-receipt-anywhere w-here , receipt-in-sharpened-frame -- is not free. ------------------------------------------------------------------------ --- The axioms the policy's PROVEN-WITH-AXIOMS token is *about*. Naming one --- is the entire difference between that token and `proven`: asserting the --- proposition alone would make the two indistinguishable, which is the --- accounting §5 exists to keep. +-- The applicable frame is part of a receipt's type. Keeping the coarse +-- countermodel does not make successful assertions in the sharpened +-- frame impossible. +data Frame : Set where + coarse sharpened : Frame + +Accessible : Frame → World → World → Set +Accessible coarse = _≈_ +Accessible sharpened = _≈′_ + +FrameReceipt : Frame → Atom → World → Set +FrameReceipt coarse = Receipt +FrameReceipt sharpened = Receipt′ + +frame-receipt-factive : + ∀ {frame φ w} → FrameReceipt frame φ w → ⟦ φ ⟧ w +frame-receipt-factive {coarse} = receipt-factive +frame-receipt-factive {sharpened} = receipt′-factive + +FrameWarrant : Frame → Atom → World → Set +FrameWarrant frame φ w = + Σ[ e ∈ Evidence φ ] (Accessible frame w (grounds e)) × ⟦ φ ⟧ (grounds e) + +-- A bounded check has a nonempty, explicitly enumerated population and +-- a result for every sample. This is semantic test evidence in the +-- model, not an attestation that an external test runner executed. +-- Its results establish the claim only at the sampled worlds. +record TestEvidence (φ : Atom) : Set where + constructor checked-samples + field + extra-samples : ℕ + samples : Vec World (suc extra-samples) + passed : All (λ w → ⟦ φ ⟧ w) samples + +one-passing-sample : TestEvidence sound +one-passing-sample = checked-samples zero (w-here ∷ []) (tt ∷ []) + +-- A false sample cannot be hidden inside an all-passing result. +false-sample-rejected : + ∀ {n} {rest : Vec World n} → + All (λ w → ⟦ sound ⟧ w) (w-there ∷ rest) → ⊥ +false-sample-rejected (() ∷ _) + +-- These are names, not proofs of the named axioms. The interpretation +-- `assumptions` is supplied explicitly at the boundary. A postulated +-- assertion stores a function from that proposition to a receipt, not +-- an unconditional receipt paired with an inert label. data Axiom : Set where funext : Axiom choice : Axiom @@ -192,21 +240,66 @@ data Verdict : Set where argued : Verdict no-claim : Verdict -- the `open` token (`open` is a keyword) -Asserts : Verdict → (φ : Atom) → World → Set -Asserts proven φ w = Receipt φ w -Asserts proven-with-postulates φ w = Axiom × Receipt φ w -Asserts tested-only φ w = Warrant φ w -Asserts argued φ w = Warrant φ w -Asserts no-claim φ w = ⊤ +Asserts : (Axiom → Set) → Frame → Verdict → (φ : Atom) → World → Set +Asserts assumptions frame proven φ w = FrameReceipt frame φ w +Asserts assumptions frame proven-with-postulates φ w = + Σ[ ax ∈ Axiom ] (assumptions ax → FrameReceipt frame φ w) +Asserts assumptions frame tested-only φ w = TestEvidence φ +Asserts assumptions frame argued φ w = FrameWarrant frame φ w +Asserts assumptions frame no-claim φ w = ⊤ --- A `proven-with-postulates` assertion carries the axiom it rests on; a --- `proven` assertion carries none, which is why they are different types. postulates-name-an-axiom : - ∀ {φ w} → Asserts proven-with-postulates φ w → Axiom + ∀ {assumptions frame φ w} → + Asserts assumptions frame proven-with-postulates φ w → Axiom postulates-name-an-axiom = proj₁ --- The same warrant never licences the stronger token. +discharge-postulates : + ∀ {assumptions frame φ w} + (claim : Asserts assumptions frame proven-with-postulates φ w) → + assumptions (proj₁ claim) → Asserts assumptions frame proven φ w +discharge-postulates claim premise = proj₂ claim premise + +-- A positive control: the stronger token is inhabited in the sharpened +-- frame. Warrant and receipt may coexist; the theorem below rejects a +-- uniform conversion, not the coexistence of the two kinds of evidence. +proven-in-sharpened-frame : + ∀ {assumptions} → Asserts assumptions sharpened proven sound w-here +proven-in-sharpened-frame = receipt-in-sharpened-frame + +warrant-and-receipt : + ∀ {assumptions} → + Asserts assumptions sharpened argued sound w-here × + Asserts assumptions sharpened proven sound w-here +warrant-and-receipt = + (ev-sound , ≈′-here , tt) , receipt-in-sharpened-frame + +-- No operation may promote every warrant in every frame. The coarse +-- false-world witness refutes such an operation without making the +-- sharpened positive control impossible. no-free-promotion : - ∀ {φ w} → Asserts argued φ w → Asserts proven φ w → ⊥ -no-free-promotion {sound} {w-here} _ r = r ≈-cross -no-free-promotion {sound} {w-there} _ r = r ≈-there + ∀ {assumptions} → + (∀ {frame φ w} → Asserts assumptions frame argued φ w → + Asserts assumptions frame proven φ w) → ⊥ +no-free-promotion promote = + promote {coarse} {sound} {w-there} warrant-elsewhere ≈-there + +-- The opaque names do not supply their interpretations. In particular, +-- an implication from an unavailable premise cannot be projected into +-- an unconditional receipt. This countermodel does not assert that +-- any actual mathematical axiom is false. +no-unconditional-postulate-promotion : + (∀ {assumptions frame φ w} → + Asserts assumptions frame proven-with-postulates φ w → + Asserts assumptions frame proven φ w) → ⊥ +no-unconditional-postulate-promotion promote = + promote {λ _ → ⊥} {sharpened} {sound} {w-there} + (em , λ impossible → ⊥-elim impossible) ≈′-there + +-- A sample at w-here can pass while the unsampled w-there is false. +-- Explicit bounds prevent a tested-only claim from becoming universal. +no-test-promotion : + ∀ {assumptions} → + (∀ {frame φ w} → Asserts assumptions frame tested-only φ w → + Asserts assumptions frame proven φ w) → ⊥ +no-test-promotion promote = + promote {sharpened} {sound} {w-there} one-passing-sample ≈′-there diff --git a/scripts/ci/audit-dependencies.sh b/scripts/ci/audit-dependencies.sh new file mode 100644 index 00000000..93c31658 --- /dev/null +++ b/scripts/ci/audit-dependencies.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# Exit 0: audited without vulnerabilities; 1: finding; 2: incomplete/NOT RUN. +set -euo pipefail + +for tool in cargo-audit jq; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "::error::$tool is missing; dependency audit NOT RUN" + exit 2 + fi +done +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +cd -- "$repo_root" +report_dir=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/echidna-audit-report.XXXXXX") +summary="${GITHUB_STEP_SUMMARY:-$report_dir/summary.md}" +{ + echo '## Dependency audit' + echo + printf 'Revision: `%s`. Tool: `%s`.\n\n' "$(git rev-parse HEAD)" "$(cargo-audit --version)" +} >> "$summary" + +status=0 +for lockfile in Cargo.lock fuzz/Cargo.lock; do + if [[ ! -f "$lockfile" ]]; then + echo "::error::$lockfile is missing; dependency audit NOT RUN" + printf '### %s — NOT RUN (lockfile missing)\n\n' "$lockfile" >> "$summary" + status=2 + continue + fi + report="$report_dir/${lockfile//\//-}.json" + stderr="$report.stderr" + audit_exit=0 + cargo-audit audit --file "$lockfile" --json > "$report" 2> "$stderr" || audit_exit=$? + cat "$stderr" + if ! jq -e '(.vulnerabilities.found | type) == "boolean"' "$report" >/dev/null 2>&1; then + result='NOT RUN (no complete audit report)' + status=2 + elif jq -e '.vulnerabilities.found' "$report" >/dev/null; then + result='FAILED (vulnerabilities found)' + if [[ "$status" -eq 0 ]]; then status=1; fi + elif [[ "$audit_exit" -ne 0 ]]; then + result="NOT RUN (tool exited $audit_exit)" + status=2 + else + result='PASSED (no known vulnerabilities)' + fi + printf '%s: %s\n' "$lockfile" "$result" + { + printf '### %s — %s\n\n' "$lockfile" "$result" + printf 'Lockfile SHA-256: `%s`.\n\n' "$(sha256sum "$lockfile" | cut -d ' ' -f 1)" + echo 'Tool output from this execution (no second audit):' + echo + echo '```json' + cat "$report" + echo + echo '```' + echo + } >> "$summary" +done +exit "$status" diff --git a/scripts/ci/check-agda.sh b/scripts/ci/check-agda.sh new file mode 100644 index 00000000..beac224d --- /dev/null +++ b/scripts/ci/check-agda.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# Check the real corpus and require a fresh interface for each full source path. +set -euo pipefail + +if ! command -v agda >/dev/null 2>&1 || [[ ! -f "${AGDA_LIBRARIES_FILE:-}" ]]; then + echo "::error::Pinned Agda setup is missing; proof check NOT RUN" + exit 2 +fi +version=$(agda --version) +if [[ "$version" != "Agda version 2.7.0.1" ]]; then + echo "::error::Unexpected compiler: $version; proof check NOT RUN" + exit 2 +fi + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +cd -- "${1:-$repo_root/meta-checker}" +# No interface from a previous attempt may establish current-run coverage. +find . -type f -name '*.agdai' -delete +# PROOF-POLICY §2.1: do not weaken these flags. UnsupportedIndexedMatch +# warnings describe the existing cubical-transport limitation (PROOF-STATUS R4). +agda --no-default-libraries --library-file "$AGDA_LIBRARIES_FILE" \ + --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda + +missing=0 +total=0 +while IFS= read -r -d '' source; do + total=$((total + 1)) + interface="_build/2.7.0.1/agda/${source%.agda}.agdai" + if [[ ! -f "$interface" ]]; then + echo "::error::$source produced no interface — module is outside the checked closure" + missing=$((missing + 1)) + fi +done < <(find src -type f -name '*.agda' -print0) +echo "$total source modules, $((total - missing)) with fresh interfaces" +[[ "$total" -gt 0 && "$missing" -eq 0 ]] +bash "$repo_root/scripts/ci/check-receipt-controls.sh" diff --git a/scripts/ci/check-receipt-controls.sh b/scripts/ci/check-receipt-controls.sh new file mode 100644 index 00000000..666c5502 --- /dev/null +++ b/scripts/ci/check-receipt-controls.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# Accept actual model witnesses; reject three former verdict promotions. +set -euo pipefail + +if ! command -v agda >/dev/null 2>&1 || [[ ! -f "${AGDA_LIBRARIES_FILE:-}" ]]; then + echo '::error::Pinned Agda setup is missing; receipt controls NOT RUN' + exit 2 +fi +if [[ $(agda --version) != 'Agda version 2.7.0.1' ]]; then + echo '::error::Unexpected Agda compiler; receipt controls NOT RUN' + exit 2 +fi + +controls_dir=$(mktemp -d) +trap 'rm -rf -- "$controls_dir"' EXIT + +cat > "$controls_dir/AcceptReceiptEvidence.agda" <<'AGDA' +module AcceptReceiptEvidence where +open import Echidna.Receipt +open import Data.Unit using (⊤) + +checked-receipt : Asserts (λ _ → ⊤) sharpened proven sound w-here +checked-receipt = proven-in-sharpened-frame {assumptions = λ _ → ⊤} + +checked-sample : Asserts (λ _ → ⊤) coarse tested-only sound w-there +checked-sample = one-passing-sample +AGDA + +cat > "$controls_dir/RejectPostulateProjection.agda" <<'AGDA' +module RejectPostulateProjection where +open import Echidna.Receipt +open import Data.Product using (proj₂) + +bad-postulate-projection : + ∀ {assumptions frame φ w} → + Asserts assumptions frame proven-with-postulates φ w → + Asserts assumptions frame proven φ w +bad-postulate-projection claim = proj₂ claim +AGDA + +cat > "$controls_dir/RejectWarrantAsTest.agda" <<'AGDA' +module RejectWarrantAsTest where +open import Echidna.Receipt +open import Data.Unit using (⊤) + +bad-warrant-as-test : Asserts (λ _ → ⊤) coarse tested-only sound w-there +bad-warrant-as-test = warrant-elsewhere +AGDA + +cat > "$controls_dir/RejectFalseSample.agda" <<'AGDA' +module RejectFalseSample where +open import Echidna.Receipt +open import Data.Nat using (zero) +open import Data.Unit using (tt) +open import Data.Vec using ([]; _∷_) +open import Data.Vec.Relation.Unary.All using ([]; _∷_) + +bad-false-sample : TestEvidence sound +bad-false-sample = checked-samples zero (w-there ∷ []) (tt ∷ []) +AGDA + +check_control() { + local module=$1 expected=$2 log="$controls_dir/$1.log" result=0 + timeout --signal=TERM --kill-after=5s 90s agda \ + --no-default-libraries --library-file "$AGDA_LIBRARIES_FILE" \ + --library echidna-meta --include-path "$controls_dir" \ + --safe --without-K --cubical-compatible "$controls_dir/$module.agda" \ + > "$log" 2>&1 || result=$? + if [[ "$result" -eq 124 || "$result" -eq 137 ]]; then + echo "::error::$module timed out; receipt controls NOT RUN" + exit 2 + fi + if [[ "$expected" == accept ]]; then + if [[ "$result" -ne 0 ]]; then + cat "$log" + echo "::error::$module did not accept the valid witnesses" + exit 1 + fi + else + # Require the intended fixture and rejected expression. An import, + # setup or parse error must never count as a rejecting control. + if [[ "$result" -eq 0 ]] || ! grep -Fq "$controls_dir/$module.agda:" "$log" || \ + ! tr '\n' ' ' < "$log" | grep -Fq "$expected"; then + cat "$log" + echo "::error::$module did not reject the intended expression" + exit 1 + fi + fi + echo "$module: $expected" +} + +check_control AcceptReceiptEvidence accept +check_control RejectPostulateProjection 'when checking that the expression proj₂ claim has type' +check_control RejectWarrantAsTest 'when checking that the expression warrant-elsewhere has type' +check_control RejectFalseSample 'when checking that the expression tt has type' diff --git a/scripts/ci/install-cargo-audit.sh b/scripts/ci/install-cargo-audit.sh new file mode 100644 index 00000000..8eb33102 --- /dev/null +++ b/scripts/ci/install-cargo-audit.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# Use the upstream release binary instead of rebuilding cargo-audit in every job. +set -euo pipefail +trap 'echo "::error::cargo-audit setup failed; dependency audit NOT RUN"; exit 2' ERR + +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + echo "::error::The pinned cargo-audit distribution supports Linux x86_64; audit NOT RUN" + exit 2 +fi +tool_dir=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/echidna-cargo-audit.XXXXXX") +archive="$tool_dir/cargo-audit.tgz" +curl --fail --location --retry 3 --output "$archive" \ + https://github.com/rustsec/rustsec/releases/download/cargo-audit/v0.22.2/cargo-audit-x86_64-unknown-linux-musl-v0.22.2.tgz +printf '%s %s\n' \ + 7fb9497f8594b389e5fce5ef9b92db08432996895b2e0c5a0167a69ed445c428 \ + "$archive" | sha256sum --check +tar --no-same-owner -xzf "$archive" -C "$tool_dir" +install_dir="$tool_dir/cargo-audit-x86_64-unknown-linux-musl-v0.22.2" +"$install_dir/cargo-audit" --version +if [[ -n "${GITHUB_PATH:-}" ]]; then + printf '%s\n' "$install_dir" >> "$GITHUB_PATH" +fi +printf 'cargo-audit executable: %s\n' "$install_dir/cargo-audit" diff --git a/scripts/ci/run-live-prover.sh b/scripts/ci/run-live-prover.sh new file mode 100644 index 00000000..2dbff5e2 --- /dev/null +++ b/scripts/ci/run-live-prover.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# Require exactly one selected live smoke test; absent tools are NOT RUN. +set -euo pipefail + +backend=${1:?usage: run-live-prover.sh BACKEND} +case "$backend" in + z3|cvc5|vampire|eprover|minizinc) executable=$backend ;; + alt-ergo) executable=alt-ergo ;; + spass) executable=SPASS ;; + glpk) executable=glpsol ;; + chuffed) executable=fzn-chuffed ;; + *) echo "::error::Unsupported required backend: $backend"; exit 2 ;; +esac +for tool in cargo "$executable"; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "::error::Required command $tool is absent for $backend; live smoke check NOT RUN" + exit 2 + fi +done + +test_name="live_${backend//-/_}_version" +if ! listing=$(cargo test --locked -p echidna --test live_prover_suite --features live-provers -- --list --exact "$test_name"); then + echo "::error::Could not build/list the selected test; live smoke check NOT RUN" + exit 2 +fi +if ! printf '%s\n' "$listing" | grep -Fxq "$test_name: test"; then + echo "::error::Selected test $test_name is absent; live smoke check NOT RUN" + exit 2 +fi +if ! ECHIDNA_REQUIRE_LIVE_PROVER=1 cargo test --locked -p echidna \ + --test live_prover_suite --features live-provers -- --nocapture --exact "$test_name"; then + echo "::error::Selected $backend live smoke test failed" + exit 1 +fi diff --git a/scripts/ci/setup-agda.sh b/scripts/ci/setup-agda.sh new file mode 100644 index 00000000..ba0e4601 --- /dev/null +++ b/scripts/ci/setup-agda.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# Pinned upstream binaries: no cold GHC bootstrap and no cached proof results. +set -Eeuo pipefail +trap 'echo "::error::Agda setup did not complete; proof check NOT RUN"; exit 2' ERR + +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + echo "::error::The pinned Agda distribution supports Linux x86_64; proof check NOT RUN" + exit 2 +fi + +agda_version=2.7.0.1 +stdlib_version=2.1 +cache_dir="${AGDA_ARCHIVE_CACHE:-${HOME}/.cache/echidna/agda}" +mkdir -p "$cache_dir" +toolchain_dir=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/echidna-agda.XXXXXX") +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) + +fetch_verified() { + local url=$1 archive=$2 digest=$3 + if [[ -f "$archive" ]] && ! printf '%s %s\n' "$digest" "$archive" | sha256sum --check --status; then + echo "::warning::Discarding a corrupt cached archive: ${archive##*/}" + rm -- "$archive" + fi + if [[ ! -f "$archive" ]]; then + curl --fail --location --retry 3 --output "$archive.download" "$url" + printf '%s %s\n' "$digest" "$archive.download" | sha256sum --check + mv -- "$archive.download" "$archive" + fi + # Recheck warm restores too: a cache hit alone never authenticates a compiler. + printf '%s %s\n' "$digest" "$archive" | sha256sum --check +} + +agda_archive="$cache_dir/Agda-v${agda_version}-linux.tar.xz" +stdlib_archive="$cache_dir/agda-stdlib-${stdlib_version}.tar.gz" +fetch_verified \ + "https://github.com/agda/agda/releases/download/v${agda_version}/Agda-v${agda_version}-linux.tar.xz" \ + "$agda_archive" 975b515a14877ab6bce5bfc641e16c4a3ee52ca5d7f846577dee80aa2145db8a +fetch_verified \ + "https://github.com/agda/agda-stdlib/archive/refs/tags/v${stdlib_version}.tar.gz" \ + "$stdlib_archive" 72ca3ea25094efa0439e106f0d949330414232ec4cc5c3c3316e7e70dd06d431 + +tar --no-same-owner -xJf "$agda_archive" -C "$toolchain_dir" +tar --no-same-owner -xzf "$stdlib_archive" -C "$toolchain_dir" +agda_dir="$toolchain_dir/Agda-v${agda_version}" +libraries="$toolchain_dir/libraries" +printf '%s\n' \ + "$toolchain_dir/agda-stdlib-${stdlib_version}/standard-library.agda-lib" \ + "$repo_root/meta-checker/echidna-meta.agda-lib" > "$libraries" +"$agda_dir/bin/agda" --version + +if [[ -n "${GITHUB_ENV:-}" && -n "${GITHUB_PATH:-}" ]]; then + { + printf 'Agda_datadir=%s\n' "$agda_dir/data" + printf 'AGDA_LIBRARIES_FILE=%s\n' "$libraries" + printf 'LC_ALL=C.UTF-8\n' + } >> "$GITHUB_ENV" + printf '%s\n' "$agda_dir/bin" >> "$GITHUB_PATH" +fi +printf 'Agda data: %s\nLibraries: %s\nExecutable: %s\n' \ + "$agda_dir/data" "$libraries" "$agda_dir/bin/agda" diff --git a/scripts/install-proof-toolchains.sh b/scripts/install-proof-toolchains.sh index 00362a6e..bf8fd005 100755 --- a/scripts/install-proof-toolchains.sh +++ b/scripts/install-proof-toolchains.sh @@ -28,9 +28,10 @@ # # stdlib: codeload.github.com/agda/agda-stdlib/tar.gz/refs/tags/v2.1 # # Downloading is not enough — it must be *registered*, or every module that # # imports Agda.Primitive fails: +# mkdir -p ~/.agda # echo "/tmp/agda-stdlib-2.1/standard-library.agda-lib" >> ~/.agda/libraries -# # the tarball's `name:` line is `standard-library-2.1`, and `defaults` (or a -# # .agda-lib `depend:`) must match that name, not the bare `standard-library` +# # Register the pinned version explicitly as the default. The project's +# # bare standard-library dependency resolves to this registered version. # echo "standard-library-2.1" >> ~/.agda/defaults # echo "$PWD/meta-checker/echidna-meta.agda-lib" >> ~/.agda/libraries # export LC_ALL=C.UTF-8 # Unicode identifiers diff --git a/src/rust/provers/chuffed.rs b/src/rust/provers/chuffed.rs index 96808cae..4566a59e 100644 --- a/src/rust/provers/chuffed.rs +++ b/src/rust/provers/chuffed.rs @@ -43,7 +43,20 @@ impl ProverBackend for ChuffedBackend { ProverKind::Chuffed } async fn version(&self) -> Result { - Ok("chuffed".to_string()) + // Upstream fzn-chuffed has no --version option. Require a successful + // real process response instead of returning a constant without exec. + let output = Command::new(&self.config.executable) + .arg("--help") + .output() + .await + .context("Failed to execute fzn-chuffed --help")?; + anyhow::ensure!(output.status.success(), "fzn-chuffed --help failed"); + let help = String::from_utf8_lossy(&output.stdout); + let banner = help + .lines() + .find(|line| !line.trim().is_empty()) + .context("fzn-chuffed --help returned no output")?; + Ok(format!("Chuffed (version unavailable; --help): {banner}")) } async fn parse_file(&self, path: PathBuf) -> Result { let content = super::bounded_read_proof_file(&path).await?; diff --git a/tests/live_prover_suite.rs b/tests/live_prover_suite.rs index 16d33fd5..2a226bd0 100644 --- a/tests/live_prover_suite.rs +++ b/tests/live_prover_suite.rs @@ -13,11 +13,14 @@ // // Skip semantics: every test first runs `which ` via the `which` // crate; if the binary is absent the test is *skipped* (returns Ok) rather -// than failing. This lets the same suite run locally (sparse tool install) -// and in CI (per-job single binary) without branching. +// than failing for a local sparse installation. CI sets +// ECHIDNA_REQUIRE_LIVE_PROVER=1 for its selected test: absence then fails. +// Backend construction errors always fail; they are not missing-tool skips. // -// Wave-1 (this file): Tier-1 backends — version-check smoke tests. Proves +// Wave-1 (this file): Tier-1 backends — process/version smoke tests. Shows // the subprocess wiring is real, not mocked, and that the binary responds. +// Chuffed has no version flag; its smoke check validates the --help response. +// These are not checks of logical proof validity. // L3 hygiene: emits VeriSimDB records for each test outcome via // `record_proof_attempt`, feeding the learning loop. // @@ -60,7 +63,9 @@ fn live_config(executable: &str) -> ProverConfig { fn try_live_backend(kind: ProverKind, exe: &str) -> Option> { let _resolved = binary_on_path(exe)?; let config = live_config(exe); - ProverFactory::create(kind, config).ok() + Some(ProverFactory::create(kind, config).unwrap_or_else(|error| { + panic!("{} backend construction failed: {error}", kind_label(kind)) + })) } /// Emit a VeriSimDB proof-attempt record for the test outcome. @@ -94,7 +99,7 @@ async fn emit_live_result(kind: ProverKind, exe: &str, version: Option<&str>, el repo: "hyperpolymath/echidna".to_string(), file: "tests/live_prover_suite.rs".to_string(), claim: format!( - "Prover {} is reachable and can report version", + "Prover {} is reachable and reports identifying output", kind_label(kind) ), obligation_class: "system_integration".to_string(), @@ -133,6 +138,12 @@ async fn emit_live_result(kind: ProverKind, exe: &str, version: Option<&str>, el /// exists to catch. Also emits a VeriSimDB record on completion. async fn assert_version_reachable(kind: ProverKind, exe: &str) { let Some(backend) = try_live_backend(kind, exe) else { + assert!( + std::env::var("ECHIDNA_REQUIRE_LIVE_PROVER").as_deref() != Ok("1"), + "NOT RUN: required {} binary is absent from PATH (searched for `{}`)", + kind_label(kind), + exe, + ); eprintln!( "SKIP: {} not on PATH (searched for `{}`)", kind_label(kind), @@ -142,12 +153,15 @@ async fn assert_version_reachable(kind: ProverKind, exe: &str) { }; let start_time = Instant::now(); - match backend.version().await { + let response = tokio::time::timeout(std::time::Duration::from_secs(30), backend.version()) + .await + .unwrap_or_else(|_| panic!("{} process/version check timed out", kind_label(kind))); + match response { Ok(v) => { let elapsed_ms = start_time.elapsed().as_millis() as u64; assert!( - !v.trim().is_empty(), - "{} version() returned empty string — subprocess is wired but produced no output", + !v.trim().is_empty() && v.trim() != "unknown", + "{} version() returned no identifying output", kind_label(kind), ); eprintln!("OK: {} reported version = {:?}", kind_label(kind), v); From 791cca0a0f4dbe726d97daab73b7064f7c77c125 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:01:23 +0000 Subject: [PATCH 07/30] fix(review): separate GraphQL startup and qualify manifest validation Move GraphQL to8082 with tested configuration errors and migration notes. Keep full DEED/K9 structural coverage while avoiding repeated shell walks of large corpora. Propagate truthful proof/runtime scope, least-privilege workflow permissions and canonical standards coordinates. Generate lock-only repair candidates through the complete canonical repair chain. --- .github/hooks/validate-a2ml.sh | 82 ++++--- .github/workflows/dogfood-gate.yml | 45 +++- .github/workflows/hypatia-scan.yml | 5 +- .github/workflows/scorecard.yml | 6 +- .github/workflows/spark-theatre-gate.yml | 2 +- .github/workflows/workflow-linter.yml | 41 +++- .machine_readable/descriptiles/META.a2ml | 10 +- CHANGELOG.adoc | 12 + CLAUDE.md | 2 +- Cargo.lock | 205 ------------------ Justfile | 2 +- QUICKSTART-USER.adoc | 13 +- README.adoc | 6 +- container/README.adoc | 11 + container/deploy.k9.ncl | 6 +- docs/ARCHITECTURE.adoc | 23 +- docs/DEBT.adoc | 44 ++-- docs/ECOSYSTEM-INTEGRATION.adoc | 4 +- docs/ENV-VARS.adoc | 13 +- docs/handover/llm-warmup-dev.adoc | 2 +- docs/handover/llm-warmup-user.adoc | 2 +- ...6-11-01-routing-and-rule-engine-recon.adoc | 52 +++-- .../ci/test-manifest-validator-regressions.sh | 53 +++++ selur-compose.toml | 2 +- site/docs/api/graphql.adoc | 16 +- site/docs/index.adoc | 6 +- src/interfaces/graphql/Cargo.toml | 2 +- src/interfaces/graphql/main.rs | 38 ++-- src/interfaces/graphql/tests/startup.rs | 130 +++++++++++ 29 files changed, 468 insertions(+), 367 deletions(-) create mode 100644 scripts/ci/test-manifest-validator-regressions.sh create mode 100644 src/interfaces/graphql/tests/startup.rs diff --git a/.github/hooks/validate-a2ml.sh b/.github/hooks/validate-a2ml.sh index b16fa852..cec442b3 100755 --- a/.github/hooks/validate-a2ml.sh +++ b/.github/hooks/validate-a2ml.sh @@ -17,6 +17,7 @@ # Exit codes: # 0 — All files valid (or only warnings in non-strict mode) # 1 — Validation errors found +# 2 — Required input scan could not run set -euo pipefail @@ -199,6 +200,12 @@ validate_a2ml() { if [[ "$line" =~ ^[[:space:]]*:schema-version[[:space:]] ]]; then has_version=true fi + # These are presence checks. Once both facts have witnesses, later + # corpus records cannot change them. Body/attestation checks below + # still inspect the complete file. + if [[ "$has_identity" == "true" && "$has_version" == "true" ]]; then + break + fi done < "$file" # AI manifest files (0-AI-MANIFEST.a2ml, 0.1-AI-MANIFEST.a2ml, etc.) @@ -299,28 +306,39 @@ validate_a2ml() { local attestation_has_content=false line_num=0 - while IFS= read -r line; do - line_num=$((line_num + 1)) - - # Detect attestation section start - if [[ "$line" =~ ^\[attestation\] ]] || [[ "$line" =~ ^##[[:space:]]+[Aa]ttestation ]] || [[ "$line" =~ ^##[[:space:]]+ATTESTATION ]]; then - in_attestation=true - attestation_line=$line_num - continue - fi - - # Detect next section (ends attestation block) - if [[ "$in_attestation" == "true" ]]; then - if [[ "$line" =~ ^\[.+\] ]] || [[ "$line" =~ ^##[[:space:]] ]]; then - in_attestation=false + # A native grep scans the whole file before choosing whether the slower + # stateful shell walk is needed. Most large training corpora have no + # attestation section; no body or path is excluded from validation. + local attestation_probe=0 + grep -qE '^\[attestation\]|^##[[:space:]]+([Aa]ttestation|ATTESTATION)' "$file" || attestation_probe=$? + if [[ "$attestation_probe" -gt 1 ]]; then + echo "::error::Cannot read $file; manifest validation NOT RUN" + exit 2 + fi + if [[ "$attestation_probe" -eq 0 ]]; then + while IFS= read -r line; do + line_num=$((line_num + 1)) + + # Detect attestation section start + if [[ "$line" =~ ^\[attestation\] ]] || [[ "$line" =~ ^##[[:space:]]+[Aa]ttestation ]] || [[ "$line" =~ ^##[[:space:]]+ATTESTATION ]]; then + in_attestation=true + attestation_line=$line_num continue fi - # Check for content in attestation block - if [[ "$line" =~ (proof|signature|verified|hash)[[:space:]]*= ]]; then - attestation_has_content=true + + # Detect next section (ends attestation block) + if [[ "$in_attestation" == "true" ]]; then + if [[ "$line" =~ ^\[.+\] ]] || [[ "$line" =~ ^##[[:space:]] ]]; then + in_attestation=false + continue + fi + # Check for content in attestation block + if [[ "$line" =~ (proof|signature|verified|hash)[[:space:]]*= ]]; then + attestation_has_content=true + fi fi - fi - done < "$file" + done < "$file" + fi if [[ $attestation_line -gt 0 && "$attestation_has_content" == "false" && "$is_manifest" == "false" ]]; then report_issue "warning" "$file" "$attestation_line" \ @@ -329,18 +347,20 @@ validate_a2ml() { # --- Check 4: Section heading syntax --- # Validate that [section] headings are well-formed (no unclosed brackets) - line_num=0 - while IFS= read -r line; do - line_num=$((line_num + 1)) - # Lines starting with [ should have a matching ] - if [[ "$line" =~ ^\[ && ! "$line" =~ ^\[.+\] ]]; then - # Exclude markdown-style links and multi-line values - if [[ ! "$line" =~ ^\[.*\]\( && ! "$line" =~ ^\[TODO && ! "$line" =~ ^\[YOUR ]]; then - report_issue "warning" "$file" "$line_num" \ - "Possibly malformed section heading: unclosed bracket" - fi - fi - done < "$file" + # Apply the same heading predicates in one native full-file pass. Preserve + # original line numbers, and fail if the scan itself cannot read the file. + local malformed_headings + if ! malformed_headings=$(awk ' + /^\[/ && !/^\[.+\]/ && !/^\[.*\]\(/ && !/^\[TODO/ && !/^\[YOUR/ { print NR } + ' "$file"); then + echo "::error::Cannot scan headings in $file; manifest validation NOT RUN" + exit 2 + fi + while IFS= read -r line_num; do + [[ -n "$line_num" ]] || continue + report_issue "warning" "$file" "$line_num" \ + "Possibly malformed section heading: unclosed bracket" + done <<< "$malformed_headings" } # --------------------------------------------------------------------------- diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 37daf073..0729c2be 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -33,6 +33,8 @@ jobs: name: Validate DEED manifests runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + validation: ${{ steps.validate.outcome }} steps: - name: Checkout repository @@ -48,8 +50,13 @@ jobs: fi - name: Validate A2ML manifests + id: validate if: steps.detect.outputs.count > 0 - run: bash .github/hooks/validate-a2ml.sh + env: + INPUT_STRICT: "true" + run: | + bash scripts/ci/test-manifest-validator-regressions.sh + bash .github/hooks/validate-a2ml.sh - name: Write summary run: | A2ML_COUNT="${{ steps.detect.outputs.count }}" @@ -65,6 +72,7 @@ jobs: echo "## A2ML Validation" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "Scanned **${A2ML_COUNT}** manifest file(s) (.deed, or legacy .a2ml). See step output for details." >> "$GITHUB_STEP_SUMMARY" + echo "Scope: identity/version fields, SPDX header, attestation-field presence and heading structure. This does not verify signatures or proof claims." >> "$GITHUB_STEP_SUMMARY" fi # --------------------------------------------------------------------------- @@ -74,6 +82,8 @@ jobs: name: Validate K9 contracts runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + validation: ${{ steps.validate.outcome }} steps: - name: Checkout repository @@ -93,7 +103,10 @@ jobs: fi - name: Validate K9 contracts + id: validate if: steps.detect.outputs.k9_count > 0 + env: + INPUT_STRICT: "true" run: bash .github/hooks/validate-k9.sh - name: Write summary run: | @@ -103,14 +116,15 @@ jobs: cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" ## K9 Contract Validation - :warning: **No .a2ml/.deed manifest files found.** Every RSR-compliant repo should have a repo deed (`_chora.deed`) at its root. + :warning: **No K9 contracts found.** Structural K9 validation was not run. Generate contracts with: `k9iser generate .` EOF else echo "## K9 Contract Validation" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Validated **${K9_COUNT}** K9 contract(s) against **${CFG_COUNT}** config file(s)." >> "$GITHUB_STEP_SUMMARY" + echo "Checked structural fields in **${K9_COUNT}** K9 contract(s). The repository contains **${CFG_COUNT}** configuration file(s); this hook does not compare their contents with the contracts." >> "$GITHUB_STEP_SUMMARY" + echo "This is not Nickel evaluation, cryptographic signature verification or Hunt deployment authorization." >> "$GITHUB_STEP_SUMMARY" fi # --------------------------------------------------------------------------- @@ -380,7 +394,16 @@ jobs: cat <> "$GITHUB_STEP_SUMMARY" ## Dogfooding Scorecard - **Score: ${SCORE}/${MAX}** + **Inventory score: ${SCORE}/${MAX}** (file/integration presence only). + + | Validator | Job result | Validation step | + |-----------|------------|-----------------| + | DEED/A2ML structure | ${{ needs.a2ml-validate.result }} | ${{ needs.a2ml-validate.outputs.validation }} | + | K9 structure | ${{ needs.k9-validate.result }} | ${{ needs.k9-validate.outputs.validation }} | + | Invisible characters | ${{ needs.empty-lint.result }} | See job log | + | Groove manifest | ${{ needs.groove-check.result }} | See job log | + | Eclexiaiser manifest | ${{ needs.eclexiaiser-validate.result }} | See job log | + | Banned runtimes | ${{ needs.runtime-ban.result }} | See job log | | Tool/Format | Status | Notes | |-------------|--------|-------| @@ -396,3 +419,17 @@ jobs: *Dogfooding is guinea pig fooding — we test our tools on ourselves.* EOF + - name: Require completed validation + if: always() + env: + VALIDATION_RESULTS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + if ! jq -e ' + all(.[]; .result == "success") and + .["a2ml-validate"].outputs.validation == "success" and + .["k9-validate"].outputs.validation == "success" + ' <<< "$VALIDATION_RESULTS" >/dev/null; then + echo "::error::A required validation failed, was cancelled, or did not run" + exit 1 + fi diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 52ec9fbc..e8a9eb8b 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -17,10 +17,13 @@ on: permissions: actions: read contents: read - security-events: write jobs: hypatia: + permissions: + actions: read + contents: read + security-events: write uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@d3e4b485a2af90c92a8e18af0888cd19cb132cd9 # published source, 2026-11-04 secrets: HYPATIA_SCAN_PAT: ${{ secrets.HYPATIA_SCAN_PAT }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 14b23c3a..4cf48da3 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -13,12 +13,10 @@ on: permissions: actions: read contents: read - id-token: write - security-events: write jobs: analysis: - # Job-level permissions REPLACE the workflow-level read-all block, so + # Job-level permissions REPLACE the workflow-level read-only block, so # contents: read must be restated here — the reusable declares it # (checkout needs it) and startup-fails if the caller doesn't grant it. permissions: @@ -26,4 +24,4 @@ jobs: security-events: write id-token: write contents: read - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@3c00687a5bbd0d881d351db8502417b5d0a8b0b0 # main 2026-10-20, standards#1315 hypatia-ref aa34a4cc (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@d3e4b485a2af90c92a8e18af0888cd19cb132cd9 # published source, 2026-11-04 diff --git a/.github/workflows/spark-theatre-gate.yml b/.github/workflows/spark-theatre-gate.yml index ca2b37c9..8a905cc2 100644 --- a/.github/workflows/spark-theatre-gate.yml +++ b/.github/workflows/spark-theatre-gate.yml @@ -20,7 +20,7 @@ permissions: jobs: spark-theatre-gate: - uses: hyperpolymath/standards/.github/workflows/spark-theatre-gate.yml@3c00687a5bbd0d881d351db8502417b5d0a8b0b0 # main 2026-10-20, standards#1315 hypatia-ref aa34a4cc (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + uses: metadatastician/standards/.github/workflows/spark-theatre-gate.yml@d3e4b485a2af90c92a8e18af0888cd19cb132cd9 # published source, 2026-11-04 with: paths: "." enforce_zero_contract: false diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 8f4c7e33..8966ac15 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -31,7 +31,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Check workflow policy and issue regressions # Duplicate YAML keys are rejected before Actions creates any jobs. @@ -65,18 +68,39 @@ jobs: GH_PROMPT_DISABLED: '1' run: | set -euo pipefail - for tool in gh jq yq; do + for tool in gh jq yq gawk; do if ! command -v "$tool" >/dev/null 2>&1; then echo "::error::$tool missing; lock refresh NOT RUN" exit 2 fi done gh extension install github/gh-actions-lock --pin v0.2.0 - gh api 'repos/metadatastician/standards/contents/scripts/update-actions-lock.sh?ref=d3e4b485a2af90c92a8e18af0888cd19cb132cd9' \ - --jq .content | base64 --decode > "$RUNNER_TEMP/update-actions-lock.sh" - bash "$RUNNER_TEMP/update-actions-lock.sh" .github/workflows + authority="$RUNNER_TEMP/lock-authority" + subject="$RUNNER_TEMP/lock-subject" + authority_sha=d3e4b485a2af90c92a8e18af0888cd19cb132cd9 + git init -q "$authority" + git -C "$authority" remote add origin https://github.com/metadatastician/standards.git + git -C "$authority" fetch -q --depth 1 origin "$authority_sha" + git -C "$authority" checkout -q --detach FETCH_HEAD + test "$(git -C "$authority" rev-parse HEAD)" = "$authority_sha" + source_sha=$(git rev-parse HEAD) + git clone -q --no-hardlinks "$GITHUB_WORKSPACE" "$subject" + git -C "$subject" checkout -q --detach "$source_sha" + git -C "$subject" remote set-url origin "https://github.com/$GITHUB_REPOSITORY.git" + export REGEN_TOOLS="$authority/scripts" + # Reuse the authority's complete repair and verification sequence. + # It restores all workflow bytes before closing/pruning the lock; + # raw generator rewrites to floating refs are never a candidate. + source "$REGEN_TOOLS/regen-dependabot-locks.sh" + outcome=$(regen_lock "$subject" 2> "$RUNNER_TEMP/actions-lock-refresh.log") + cat "$RUNNER_TEMP/actions-lock-refresh.log" + case "$outcome" in + changed|unchanged) ;; + *) echo "::error::Canonical lock generation failed: $outcome"; exit 2 ;; + esac + git -C "$subject" diff --quiet -- . ':(exclude).github/workflows/actions.lock' patch="$RUNNER_TEMP/actions-lock-refresh.patch" - git diff --binary -- .github/workflows > "$patch" + git -C "$subject" diff --binary -- .github/workflows/actions.lock > "$patch" if [ "$(wc -c < "$patch")" -gt 900000 ]; then echo "::error::Candidate exceeds the summary budget; lock refresh needs artifact retrieval" exit 2 @@ -84,8 +108,9 @@ jobs: { echo '## Official actions.lock refresh candidate' echo - echo "Source checkout: $(git rev-parse HEAD)" - echo 'Uncommitted candidate only. Re-run canonical governance on the committed result.' + echo "Source checkout: $source_sha" + echo "Canonical generation outcome: $outcome" + echo 'Uncommitted lock-only candidate; all workflow bytes preserved. Re-run canonical governance on the committed result.' echo echo '````diff' cat "$patch" diff --git a/.machine_readable/descriptiles/META.a2ml b/.machine_readable/descriptiles/META.a2ml index 35e25613..adbb4c46 100644 --- a/.machine_readable/descriptiles/META.a2ml +++ b/.machine_readable/descriptiles/META.a2ml @@ -9,7 +9,7 @@ author = "Jonathan D.A. Jewell " license = "MPL-2.0" standard = "RSR 2026" version = "2.3.0" -last-updated = "2026-10-05" +last-updated = "2026-11-04" status = "active" [project-context] @@ -31,13 +31,11 @@ secondary = ["Chapel", "Bun", "Guix"] # Four listeners, four binaries (recon 2026-11-01; see # docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc §1). core-server = 8081 # `echidna server` (--port default); routes /api/* -graphql = 8081 # `echidna-graphql`; POST / = GraphQL, GET / = playground. - # Collides with core-server above. Default kept for - # client compatibility; ECHIDNA_GRAPHQL_ADDR overrides. +graphql = 8082 # `echidna-graphql`; POST / = GraphQL, GET / = playground. + # Distinct default since 2026-11-04; ECHIDNA_GRAPHQL_ADDR overrides. grpc = 50051 # `echidna-grpc`; IPv6 loopback only rest = 8000 # `echidna-rest`; /api/v1/* + /health (ECHIDNA_REST_ADDR) -# standards 0-canon/PORT-REGISTRY.adoc: one port per project, no sharing. The -# 8081 duplication is open debt (docs/DEBT.adoc D8), not an allocation. +# These are actual binary defaults, not a claim of estate-wide port allocation. # ───────────────────────────────────────────────────────────────────────────── # Saturation campaign 2026-06-01 — additive surface declaration diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 22a3e899..465dfc81 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -8,6 +8,18 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. === [Unreleased] +==== Changed — GraphQL listener default, 2026-11-04 + +* `+echidna-graphql+` now defaults to `+127.0.0.1:8082+`, avoiding the +core `+echidna server+` listener on 8081. Update clients and reverse-proxy +upstreams that used the old GraphQL default. To retain the previous address, +set `+ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8081+` explicitly and run the core +listener on a different address. +* The GraphQL endpoint and playground remain `+POST /+` and `+GET /+`; +`+/health+` remains the process health route. Hostname overrides still work. +Malformed or occupied bind addresses now return a contextual startup error +instead of panicking. + ==== Added — `echidna lexicon scan`, 2026-10-27 * *`+echidna lexicon scan+`* (`+src/rust/lexicon/scan.rs+`, diff --git a/CLAUDE.md b/CLAUDE.md index faeb9acc..9a6373b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ echidna/ │ │ ├── main.rs # CLI entry point │ │ └── lib.rs # Library root │ ├── interfaces/ # API interfaces (workspace members) -│ │ ├── graphql/ # GraphQL (async-graphql, port 8081) +│ │ ├── graphql/ # GraphQL (async-graphql, port 8082) │ │ ├── grpc/ # gRPC (tonic, port 50051) │ │ └── rest/ # REST (axum + OpenAPI, port 8000) │ ├── abi/ # Idris2 formal ABI (EchidnaABI.TacticRecord, …) diff --git a/Cargo.lock b/Cargo.lock index ae5c0165..0bf91e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,26 +682,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1320,21 +1300,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1704,22 +1669,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1738,11 +1687,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -2110,23 +2057,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nibble_vec" version = "0.1.0" @@ -2257,49 +2187,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openssl" -version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "page_size" version = "0.6.0" @@ -2441,12 +2328,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "plotters" version = "0.3.7" @@ -2910,20 +2791,15 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2934,7 +2810,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http 0.6.11", @@ -3143,15 +3018,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "1.2.1" @@ -3184,29 +3050,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -3541,27 +3384,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -3676,16 +3498,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4169,12 +3981,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -4442,17 +4248,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/Justfile b/Justfile index 7e05062e..20d04afd 100644 --- a/Justfile +++ b/Justfile @@ -965,7 +965,7 @@ tour: echo " src/rust/agent/ - Agentic proof search (actor model)" echo "" echo "2. API INTERFACES: src/interfaces/" - echo " graphql/ - async-graphql (port 8081)" + echo " graphql/ - async-graphql (port 8082)" echo " grpc/ - tonic gRPC (port 50051)" echo " rest/ - axum + OpenAPI (port 8000)" echo "" diff --git a/QUICKSTART-USER.adoc b/QUICKSTART-USER.adoc index a07ec2c5..9e0eefce 100644 --- a/QUICKSTART-USER.adoc +++ b/QUICKSTART-USER.adoc @@ -133,21 +133,24 @@ curl -X POST http://127.0.0.1:8081/api/prove \ The full route table is in `src/rust/server.rs`. Separate GraphQL / gRPC / REST interface crates live under `src/interfaces/` -(gRPC 50051, REST 8000, GraphQL 8081). They are independent binaries, not +(gRPC 50051, REST 8000, GraphQL 8082). They are independent binaries, not one service on four ports: a GraphQL proof does not take the same path as `/api/prove` here. The dispatcher is reached by `/api/prove` (only in a `--features verisim` build) *and* by `/api/verify_parallel`, which calls `ProverDispatcher::verify_proof_parallel` unconditionally. -Note that `echidna-graphql` also defaults to 8081, the same port as -`echidna server` above — whichever starts second cannot bind. Run one or -the other, or give GraphQL its own address: +`echidna-graphql` defaults to 8082 and can run alongside the core server +on 8081. Start it with: [source,bash] ---- -ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8082 cargo run -p echidna-graphql +cargo run -p echidna-graphql ---- +`ECHIDNA_GRAPHQL_ADDR=host:port` overrides this listener. A deployment +retaining GraphQL on the previous 8081 default must set that value +explicitly and keep the core listener on another address. + See `docs/ARCHITECTURE.adoc` §"Client-facing surfaces, and what VeriSimDB is not" and `docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc`. diff --git a/README.adoc b/README.adoc index 70e04deb..4752cb45 100644 --- a/README.adoc +++ b/README.adoc @@ -191,14 +191,14 @@ dispatch, including a per-prover cwd/filename-override hook in `+tryProver+` so each backend can be invoked with its required workspace shape (Idris2 / Agda case studies in link:src/chapel/[`+src/chapel/+`]). * *Three interface binaries*, alongside the core server: -** GraphQL (async-graphql, port 8081) +** GraphQL (async-graphql, port 8082) ** gRPC (tonic + Protocol Buffers, port 50051) ** REST (axum + OpenAPI/ReDoc, port 8000) These are separate processes with separate route tables, not one service on four ports. The core `echidna server` is a fourth listener (`/api/*`, port -8081 by default) and the GraphQL binary's default is the same 8081 — start -one or set `ECHIDNA_GRAPHQL_ADDR`. See +8081 by default); GraphQL defaults to 8082 and supports the +`ECHIDNA_GRAPHQL_ADDR` override. See link:docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc[the routing recon]. * *AffineScript / AffineScript UI*: AffineScript components are being diff --git a/container/README.adoc b/container/README.adoc index e10644b4..d94dc778 100644 --- a/container/README.adoc +++ b/container/README.adoc @@ -24,6 +24,17 @@ ECHIDNA uses the Stapeln container ecosystem for production deployment. == Quick Start +The image, compose and K9 probes use `+/api/health+`. A successful response +establishes that the core HTTP process is responding. The handler does not +check solver availability, dispatcher readiness or dependent services, so +the K9 readiness field has a process-level contract. Deployments that gate +traffic on those dependencies must check them separately. + +The K9 file is a Hunt-level deployment template. Its placeholder signature, +placeholder checksum and `+hunt_authorized = false+` are not deployment +authorization. The repository's K9 CI hook checks structural fields; it does +not verify a cryptographic signature or evaluate Nickel. + [source,bash] ---- # Build diff --git a/container/deploy.k9.ncl b/container/deploy.k9.ncl index ca235e5c..cce4d57a 100644 --- a/container/deploy.k9.ncl +++ b/container/deploy.k9.ncl @@ -70,8 +70,10 @@ let deployment = { container = { image = "ghcr.io/hyperpolymath/echidna", port = 8081, - # The core server serves /api/health; there is no separate /ready or - # bare /health route (see src/rust/server.rs), so both probes use it. + # Process-level readiness only: /api/health reports that this HTTP process + # responds. It does not check dispatcher, solver or dependent-service + # readiness. Consumers that gate traffic on those dependencies need their + # own checks; this probe cannot establish that stronger contract. health_check = "/api/health", readiness_check = "/api/health", }, diff --git a/docs/ARCHITECTURE.adoc b/docs/ARCHITECTURE.adoc index 7b60fdac..46b68609 100644 --- a/docs/ARCHITECTURE.adoc +++ b/docs/ARCHITECTURE.adoc @@ -5,7 +5,7 @@ machine-readable link:../.machine_readable/descriptiles/META.a2ml[`+.machine_readable/descriptiles/META.a2ml+`] (architecture decisions) and link:../.machine_readable/descriptiles/STATE.a2ml[`+STATE.a2ml+`] -(current state). Last revised: 2026-05-26. +(current state). Last revised: 2026-11-04. ECHIDNA — Extensible Cognitive Hybrid Intelligence for Deductive Neural Assistance — is the reasoning substrate of the hyperpolymath ecosystem. @@ -15,9 +15,14 @@ Two non-negotiable invariants govern every design choice: . *ML suggests; provers verify.* Neural components rank, route, and propose; formal provers carry the trust. A wrong suggestion is a wasted CPU cycle, not a wrong proof. -. *Trust is checked, not asserted.* Certificates are independently -reproduced where formats allow (Alethe, DRAT/LRAT, TSTP) — that part is -real. Solver *identity* is not checked today: the integrity step exists, +. *Trust depends on the checker and execution path.* The in-tree Alethe +checker validates step structure, not logical correctness. The DRAT/LRAT +helper invokes `+drat-trim+` when called; that is a separate capability, +not a guarantee that every proof path uses it. The `+echidna prove+` CLI +calls the selected backend directly and does not independently check a +certificate. This overview therefore makes no blanket claim of independent +Alethe or TSTP proof reproduction. Solver *identity* is not checked today: +the integrity step exists, nothing in the tree attaches a checker, so binaries are currently _assumed_ rather than verified. See the Integrity caveat below and link:DEBT.adoc[`+DEBT+`] D9. Do not read this list as a guarantee that @@ -150,15 +155,15 @@ a separate binary with its own route table and its own path to the provers: |`+echidna server+` |`+127.0.0.1:8081+` |`+/api/*+` — `/api/health`, `+/api/provers+`, `/api/prove`, `/api/verify`, `/api/session/*`, … |`+echidna-rest+` |`+127.0.0.1:8000+` |`+/api/v1/*+` plus root `+/health+` -|`+echidna-graphql+` |`+127.0.0.1:8081+` |`+POST /+` (GraphQL), `+GET /+` +|`+echidna-graphql+` |`+127.0.0.1:8082+` |`+POST /+` (GraphQL), `+GET /+` (playground), `+/health+` |`+echidna-grpc+` |`+[::1]:50051+` |`+ProofService+` over `+echidna.proto+` |=== -`+echidna server+` and `+echidna-graphql+` share a default port; start only one -without setting `+ECHIDNA_GRAPHQL_ADDR+`. The one-port-per-project rule is -`+0-canon/PORT-REGISTRY.adoc+` in hyperpolymath/standards; the collision is -open debt. +`+echidna-graphql+` moved from 8081 to 8082 on 2026-11-04 so it can run +alongside the core server. `+ECHIDNA_GRAPHQL_ADDR+` remains an override; +deployments deliberately retaining the old GraphQL address must set it +explicitly and avoid binding the core server there. *VeriSimDB is a persistence and learning adapter, not a communication path.* It sits behind the non-default `+verisim+` Cargo feature, is written to diff --git a/docs/DEBT.adoc b/docs/DEBT.adoc index 8e4405ed..df7ba09e 100644 --- a/docs/DEBT.adoc +++ b/docs/DEBT.adoc @@ -427,32 +427,6 @@ Source: link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`]. Entries here are the ones that outlive that report. -==== D8. Core server and GraphQL interface both default to port 8081 - -*P1.* Two binaries, one default port. `+echidna server+` takes `+--port+` -default 8081; `+echidna-graphql+` hard-coded 8081 -(`+src/interfaces/graphql/main.rs+`). Whichever starts second fails to bind. - -This is not merely untidy: `0-canon/PORT-REGISTRY.adoc` in -hyperpolymath/standards states "Every project MUST use a unique port. No two -projects share a port." - -Fixed so far: `+ECHIDNA_GRAPHQL_ADDR+` override (default unchanged, so no -client breaks) and corrections to `+QUICKSTART-USER.adoc+`, `+README.adoc+`, -`+docs/ARCHITECTURE.adoc+`, `+site/docs/index.adoc+`, -`+site/docs/api/graphql.adoc+` and `+.machine_readable/descriptiles/META.a2ml+`, -which variously omitted the core server or claimed the three interfaces sat -"on dedicated ports". - -Open: the default itself. Moving it needs a release note, since anything -pinned to 8081 keeps working only while the two are not co-resident. - -[source,bash] ----- -grep -n 'default_value = "8081"' src/rust/main.rs -grep -n 'ECHIDNA_GRAPHQL_ADDR' src/interfaces/graphql/main.rs ----- - ==== D9. Solver integrity is documented as enforced and is not enforced *P1.* `+docs/ARCHITECTURE.adoc+` and the published site listed solver binary @@ -500,8 +474,9 @@ bash scripts/ci/check-agda.sh *P1.* `+POST /api/prove+` compiles two ways: through `+ProverDispatcher+` with the feature, straight to the backend factory without it (`+server.rs:423+`). -A persistence flag therefore decides whether an HTTP proof is arbitrated, and -no response field distinguishes the two. +A persistence flag therefore decides whether an HTTP proof enters the +dispatcher/trust pipeline. Single-prover dispatch returns no arbitration +verdict; cross-checking is a separate path. Open: build the dispatcher unconditionally and make `+with_verisim_writer+` purely additive. Not done here because it changes what a default build @@ -538,14 +513,25 @@ backends", and a `+localhost:8000/api/v1+` base. Open: delete the tree or regenerate it from `+site/docs/+`. Left in place here because removing a documentation tree is an owner decision, not a cleanup. +[source,bash] +---- +rg -n 'ddraig build site|path:.*_site' .github/workflows/pages.yml +rg -n '/graphql|/playground|30 supported|localhost:8000/api/v1' docs-site/content +git grep -n 'docs-site' -- .github/workflows Justfile '*.toml' '*.sh' +# Inspect any hits: references alone do not establish a build/deploy consumer. +---- + ==== Fixed en route (recorded so they are not re-triaged) +* D8: `+echidna-graphql+` now defaults to 8082, distinct from the core + server's 8081. The 2026-11-04 release note records the endpoint migration; + invalid or occupied overrides return startup errors. * `+container/entrypoint.sh+` executed `+echidna serve+`; the subcommand is `+server+`. Every container start failed at `+exec+`. * `+container/compose.toml+` healthcheck probed `+/health+` and `+container/vordr.toml+` probed `+/health+` and `+/ready+`; the core server serves `+/api/health+` and never had `+/ready+`. -* `+rules/security_learning.lgt+` was dead Logtalk, loaded by nothing; +* `+rules/security_learning.lgt+` had no in-tree consumer; quarantined to `+archive/logtalk/+` with a `+DEAD SOURCE+` header. See that directory's README. * `+docs/ECOSYSTEM-INTEGRATION.adoc+` and diff --git a/docs/ECOSYSTEM-INTEGRATION.adoc b/docs/ECOSYSTEM-INTEGRATION.adoc index e882eca2..47c91a5b 100644 --- a/docs/ECOSYSTEM-INTEGRATION.adoc +++ b/docs/ECOSYSTEM-INTEGRATION.adoc @@ -121,7 +121,7 @@ after 5+ observations 5. Proposes new detection rules via PRs Logtalk rules". Hypatia's rule engine is Elixir, and ECHIDNA is a Hypatia *target* — `.hypatia-baseline.json` and `.hypatia-ignore` — not a Logtalk rule producer. The one Logtalk module that ever lived here -(`rules/security_learning.lgt`) was loaded by nothing and is quarantined at +(`rules/security_learning.lgt`) had no in-tree consumer and is quarantined at `archive/logtalk/`. See link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`] §4. @@ -199,5 +199,5 @@ Not created yet ''''' -*Last Updated*: 2026-11-01 *Maintained By*: Jonathan D.A. Jewell +*Last Updated*: 2026-11-04 *Maintained By*: Jonathan D.A. Jewell j.d.a.jewell@open.ac.uk diff --git a/docs/ENV-VARS.adoc b/docs/ENV-VARS.adoc index e2714cc3..53848308 100644 --- a/docs/ENV-VARS.adoc +++ b/docs/ENV-VARS.adoc @@ -1,6 +1,6 @@ == ECHIDNA Environment Variables -*Status*: canonical reference. Last revised: 2026-05-26. +*Status*: canonical reference. Last revised: 2026-11-04. Every environment variable the system reads, with type, default, and the files that consume it. If you add a new env var, update this table in @@ -28,9 +28,9 @@ separate from the ML server only when the GNN runs on a different host. === Interface listeners -Each listener is a separate binary. Two of them default to the same port; see -link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc+`] -§6 RD2 and `+0-canon/PORT-REGISTRY.adoc+`. +Each listener is a separate binary. GraphQL defaults to 8082 from 2026-11-04, +removing its collision with the core server on 8081. See the migration note +in link:../CHANGELOG.adoc[`+CHANGELOG.adoc+`]. [width="100%",cols="20%,20%,20%,20%,20%",options="header",] |=== @@ -39,9 +39,10 @@ link:reports/2026-11-01-routing-and-rule-engine-recon.adoc[`+docs/reports/2026-1 |`+src/interfaces/rest/main.rs:142+` |Bind address for the `+echidna-rest+` binary (`+/api/v1/*+`). Not the core server. -|`+ECHIDNA_GRAPHQL_ADDR+` |host:port |`+127.0.0.1:8081+` +|`+ECHIDNA_GRAPHQL_ADDR+` |host:port |`+127.0.0.1:8082+` |`+src/interfaces/graphql/main.rs+` |Bind address for `+echidna-graphql+`. -Same default as the core server's `+--port+`, so set this when running both. +Hostnames remain supported. Invalid values and occupied addresses produce +a startup error instead of a bind panic. |`+--port+` / `+--host+` (CLI flags, not env) |host:port |`+127.0.0.1:8081+` diff --git a/docs/handover/llm-warmup-dev.adoc b/docs/handover/llm-warmup-dev.adoc index e67d7f57..0383cb4f 100644 --- a/docs/handover/llm-warmup-dev.adoc +++ b/docs/handover/llm-warmup-dev.adoc @@ -80,7 +80,7 @@ Three workspace members under `+src/interfaces/+`: [cols=",,,",options="header",] |=== |Dir |Tech |Port |Notes -|`+graphql/+` |async-graphql |8081 |Query/Mutation/Subscription +|`+graphql/+` |async-graphql |8082 |Query/Mutation/Subscription |`+grpc/+` |tonic |50051 |4 services |`+rest/+` |axum + utoipa |8000 |OpenAPI spec |=== diff --git a/docs/handover/llm-warmup-user.adoc b/docs/handover/llm-warmup-user.adoc index d1b9e088..b5306a11 100644 --- a/docs/handover/llm-warmup-user.adoc +++ b/docs/handover/llm-warmup-user.adoc @@ -92,7 +92,7 @@ execution (trusted provers only) |=== |Interface |Port |Tech |REST |8000 |axum + OpenAPI -|GraphQL |8081 |async-graphql +|GraphQL |8082 |async-graphql |gRPC |50051 |tonic |=== diff --git a/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc b/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc index b5061712..df31a5ed 100644 --- a/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc +++ b/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc @@ -37,7 +37,7 @@ In a default build `vcl_ut::cross_prover_search_names` is a stub returning enables the feature never pays for it. *Clients do not need any of that.* echidnabot talks to `/api/*` (core server) -or `/graphql` (the separate `echidna-graphql` binary); proof-burrower looks for +or `POST /` (the separate `echidna-graphql` binary); proof-burrower looks for `echidna prove --output json` and reads one JCS line off stdout. Those three paths are independent and none of them routes through VeriSimDB. @@ -52,6 +52,11 @@ probes, <>), *one was documented and left open* (the 8081 port collisio <>), *and one is a live correctness gap* (integrity checking never configured, <>). +*Follow-up (2026-11-04):* RD2 is now repaired: the GraphQL default is 8082, +while the core server remains on 8081. The release note describes the client +migration and the explicit override for deployments retaining the old address. +The original 2026-11-01 observation is retained below as history. + == 0.1 Policy frame — what this document is allowed to claim Three normative sources bound this recon, all outside this repository. Each one @@ -176,9 +181,10 @@ only listener whose *behaviour* changes with the `+verisim+` feature. Does *not* share `+echidna server+` state, routes or trust path. |`+echidna-graphql+` (`+src/interfaces/graphql+`) -|`+127.0.0.1:8081+` — `ECHIDNA_GRAPHQL_ADDR`, default unchanged, `main.rs` +|`+127.0.0.1:8082+` — `ECHIDNA_GRAPHQL_ADDR`; changed 2026-11-04 from the 8081 observed in this recon |`+/+` (POST = GraphQL, GET = playground), `+/health+` -|Same default port as the core server. Env override added by <>. +|Separate default from the core server since the 2026-11-04 follow-up. +Env override remains available; see <>. |`+echidna-grpc+` (`+src/interfaces/grpc+`) |`+[::1]:50051+` — `main.rs:594` @@ -272,8 +278,9 @@ a client command. * without: `+ProverFactory::create+` then `+parse_string+` directly — no dispatcher. -So the presence of a *storage* feature decides whether an HTTP proof takes the -arbitration path. Two consequences worth stating plainly: +So the presence of a *storage* feature decides whether this HTTP proof enters +the dispatcher. This single-prover call does not produce an arbitration +verdict. Two consequences worth stating plainly: . A deployment that wants the trust pipeline must enable a database integration it may not have, and point it at a URL that will then be probed. @@ -325,18 +332,21 @@ Two independent surfaces, and the doc has to name which: `+server-boot-gate.yml+` exercise. * *`+echidna-rest+`* (`+/api/v1/proofs+`, `+/api/v1/provers+`, …) — port 8000, OpenAPI + ReDoc. Different paths, different session store, no `/api/prove`. -* *`+echidna-graphql+`* — `+POST /+` on 8081. Queries are `+provers+`, +* *`+echidna-graphql+`* — `+POST /+` on 8082 (8081 before 2026-11-04). Queries are `+provers+`, `+proofState(id:)+`, `+listProofs+`, `+suggestTacticsByProofId+`, `+proverStatus+`, `+health+`; mutations `+submitProof+`, `+applyTactic+`, `+cancelProof+`, `+verifyProof+`, `+suggestTactics+`. -A client that hard-codes 8081 for both REST and GraphQL is relying on two -binaries not being co-resident. See <>. +A GraphQL client using the former 8081 default must move to 8082, or deliberately +configure `ECHIDNA_GRAPHQL_ADDR` to a free address. The core server remains on +8081. See <> and the 2026-11-04 changelog migration entry. -*Trust honesty for REST clients*: `/api/verify` never enters the dispatcher -(`+server.rs:519+`), and `/api/verify_raw+` execs the solver binary directly by -design. Only `/api/prove` reaches arbitration, and only in a `+verisim+` build. -A REST client cannot tell, from the response, which of these it got: +*Trust honesty for core HTTP clients*: `/api/verify` never enters the dispatcher +(`+server.rs:519+`), and `/api/verify_raw` execs the solver binary directly by +design. `/api/prove` enters the dispatcher only in a `+verisim+` build, as a +single-prover call without arbitration. `/api/verify_parallel` enters the +dispatcher independently of that feature. A client cannot identify the trust +path from the two single-prover response shapes: `+ProveResponse+` is `+{success, goals, message}+` and `+VerifyResponse+` is `+{valid, outcome, goals_remaining, tactics_used, mode, smt_status}+`. Neither carries a schema identifier or a trust level, unlike the prove contract. That @@ -650,8 +660,8 @@ grep -n 'cfg(feature = "verisim")' src/rust/server.rs ---- [[rd2]] -=== RD2. Core server and GraphQL interface both default to 8081 -*P1 — documented, open.* +=== RD2. Core server and GraphQL interface originally shared default 8081 +*P1 — fixed 2026-11-04; original observation retained.* `+main.rs:165+` (`+server --port+` default 8081) and `+src/interfaces/graphql/main.rs:39+` (`+127.0.0.1:8081+`, hard-coded). Whichever @@ -671,14 +681,18 @@ breaks), and explicit collision warnings in `+site/docs/index.adoc+`, `+core-server = 8081+` in the `+[interfaces]+` block of `+META.a2ml+` so the machine record cannot omit it. -Not done: moving the default. Anything pinned to 8081 keeps working; a -deployment that wants both sets the variable. +Follow-up on 2026-11-04: the GraphQL default moved to 8082, with a release +note and current interface documentation updated together. Clients using +the old GraphQL default must update their endpoint, or deliberately set +`+ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8081+` while keeping the core listener off +that address. Invalid and occupied overrides now return contextual startup +errors. *Reproduce:* [source,bash] ---- grep -n 'default_value = "8081"' src/rust/main.rs -grep -n '127.0.0.1:8081' src/interfaces/graphql/main.rs +grep -n -A 1 'VarError::NotPresent' src/interfaces/graphql/main.rs ---- [[rd3]] @@ -833,8 +847,8 @@ grep -rn 'with_integrity_checker' src/ crates/ tests/ # definition only commit for the ABI row. . <> — decouple the trust pipeline from the `+verisim+` feature. . <> — delete `+docs-site/+` or regenerate it from `+site/docs/+`. -. <> — move the GraphQL default off 8081 once a release note can carry - the change. +. **DONE 2026-11-04** — <> moved GraphQL to 8082 and added the + migration release note. . <> — repair or remove `+echidna-mcp+'s `+check_prover+`. . <>, <> — small, unambiguous, no decision needed. diff --git a/scripts/ci/test-manifest-validator-regressions.sh b/scripts/ci/test-manifest-validator-regressions.sh new file mode 100644 index 00000000..72ad75b8 --- /dev/null +++ b/scripts/ci/test-manifest-validator-regressions.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# Keep full-body checks and DEED identity/version rules when scanning large corpora. +set -euo pipefail +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +validator="$repo_root/.github/hooks/validate-a2ml.sh" +fixtures=$(mktemp -d) +trap 'rm -rf -- "$fixtures"' EXIT + +run_case() { + local name=$1 expected=$2 diagnostic=$3 result=0 + INPUT_PATH="$fixtures/$name" INPUT_STRICT=true \ + bash "$validator" > "$fixtures/$name.log" 2>&1 || result=$? + if [[ "$result" -ne "$expected" ]]; then + cat "$fixtures/$name.log" + echo "::error::$name returned $result; expected $expected" + exit 1 + fi + if [[ -n "$diagnostic" ]] && ! grep -Fq "$diagnostic" "$fixtures/$name.log"; then + cat "$fixtures/$name.log" + echo "::error::$name did not report the intended defect" + exit 1 + fi + echo "$name: expected exit $expected" +} + +for name in valid-corpus late-heading late-attestation valid-attestation; do + mkdir -p "$fixtures/$name" + { + printf '%s\n' '# SPDX-License-Identifier: MPL-2.0' 'name = "corpus"' 'version = "1"' + awk 'BEGIN { for (n = 0; n < 5000; n++) print "payload = \"unchanged record\"" }' + } > "$fixtures/$name/corpus.a2ml" +done +printf '%s\n' '[broken-heading' >> "$fixtures/late-heading/corpus.a2ml" +printf '%s\n' '[attestation]' 'description = "no proof evidence"' >> "$fixtures/late-attestation/corpus.a2ml" +printf '%s\n' '[attestation]' 'proof = "structural fixture only"' >> "$fixtures/valid-attestation/corpus.a2ml" + +mkdir -p "$fixtures/valid-deed/machine-readable" "$fixtures/missing-schema/machine-readable" "$fixtures/late-head" +printf '%s\n' '# SPDX-License-Identifier: MPL-2.0' '(estate-atlas-deed' \ + ':schema-version "1.0.0"' ':registry-version "1")' > "$fixtures/valid-deed/machine-readable/ATLAS.deed" +printf '%s\n' '# SPDX-License-Identifier: MPL-2.0' '(estate-atlas-deed' \ + ':registry-version "1")' > "$fixtures/missing-schema/machine-readable/ATLAS.deed" +printf '%s\n' '# SPDX-License-Identifier: MPL-2.0' '(unrelated)' '(estate-deed' \ + ':schema-version "1.0.0")' > "$fixtures/late-head/invalid.deed" + +run_case valid-corpus 0 '' +run_case late-heading 1 'Possibly malformed section heading' +run_case late-attestation 1 'Attestation block found but missing' +run_case valid-attestation 0 '' +run_case valid-deed 0 '' +run_case missing-schema 1 'Missing version or schema_version field' +run_case late-head 1 'Missing required identity field' diff --git a/selur-compose.toml b/selur-compose.toml index 4afcb9d3..5a0bb478 100644 --- a/selur-compose.toml +++ b/selur-compose.toml @@ -4,7 +4,7 @@ # ECHIDNA — Stapeln/Selur service definition. # # Multi-stage Rust build with Z3 SMT solver + Lean 4 prover. -# Exposes HTTP API on port 8081 (GraphQL/gRPC/REST interfaces). +# Exposes the core HTTP API on port 8081; optional interface binaries are separate. # # Usage: # podman-compose -f selur-compose.toml up -d diff --git a/site/docs/api/graphql.adoc b/site/docs/api/graphql.adoc index b4f235e1..dc468e41 100644 --- a/site/docs/api/graphql.adoc +++ b/site/docs/api/graphql.adoc @@ -1,22 +1,24 @@ == GraphQL Interface Reference The `+echidna-graphql+` binary is an optional self-hosted service. It -binds `+127.0.0.1:8081+` by default over plain HTTP and serves both the +binds `+127.0.0.1:8082+` by default over plain HTTP and serves both the GraphQL endpoint and the interactive playground at the server root — a `+POST+` to `+/+` executes operations, a `+GET+` renders the playground. There is a separate `+GET /health+`. -`+8081+` is also the default port of the core `+echidna server+`, so the two -cannot run side by side on defaults. Override this one: +The core `+echidna server+` defaults to 8081, so both can run together. +Set `+ECHIDNA_GRAPHQL_ADDR+` to override the GraphQL address: [source,bash] ---- -ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8082 ./echidna-graphql +ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8092 ./echidna-graphql ---- -GraphQL operations and the REST `+/api/+*` routes are *separate code paths*: -a GraphQL `+verifyProof+` does not pass through the same dispatch as -`+POST /api/prove+`. +GraphQL operations and the core server's `+/api/*+` routes are separate +handlers. Without `+verisim+`, both GraphQL `+verifyProof+` and core +`+POST /api/prove+` call `+ProverFactory+` directly. A `+verisim+` build +routes only the latter through `+ProverDispatcher+`. The independent +`+echidna-rest+` binary serves `+/api/v1/*+`. Documented from `+src/interfaces/graphql/{main,schema}.rs+`. diff --git a/site/docs/index.adoc b/site/docs/index.adoc index 46d588fa..9d61bef0 100644 --- a/site/docs/index.adoc +++ b/site/docs/index.adoc @@ -51,13 +51,13 @@ each binding loopback over plain HTTP: An OpenAPI-documented `+/api/v1+` surface — see the link:/docs/api/rest.html[REST interface reference]. -==== GraphQL interface (port 8081) +==== GraphQL interface (port 8082) Endpoint and interactive playground both at the server root — see the link:/docs/api/graphql.html[GraphQL interface reference]. -The default port collides with the core server above (also 8081): only one -of the two can bind. Set `+ECHIDNA_GRAPHQL_ADDR+` to run both. +GraphQL and the core server use distinct default ports. Set +`+ECHIDNA_GRAPHQL_ADDR+` to override the GraphQL listener. ==== gRPC interface (port 50051) diff --git a/src/interfaces/graphql/Cargo.toml b/src/interfaces/graphql/Cargo.toml index a3ad5604..4232b9b5 100644 --- a/src/interfaces/graphql/Cargo.toml +++ b/src/interfaces/graphql/Cargo.toml @@ -51,4 +51,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1.25.0", features = ["serde"] } # ids are minted by echidna::ids only [dev-dependencies] -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/src/interfaces/graphql/main.rs b/src/interfaces/graphql/main.rs index 29ed0428..be6217b3 100644 --- a/src/interfaces/graphql/main.rs +++ b/src/interfaces/graphql/main.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 // ECHIDNA GraphQL Interface - Main server +use anyhow::Context; use async_graphql::{EmptySubscription, Schema}; use async_graphql_axum::GraphQL; use axum::{routing::get, Router}; @@ -15,19 +16,31 @@ use schema::{MutationRoot, QueryRoot}; /// Runs the GraphQL endpoint and playground at `/` and the health check at /// `/health`, with permissive CORS. The bind address comes from -/// `ECHIDNA_GRAPHQL_ADDR`, defaulting to `127.0.0.1:8081` when it is absent or -/// not valid Unicode. FFI initialisation failure falls back to direct Rust calls. +/// `ECHIDNA_GRAPHQL_ADDR`, defaulting to `127.0.0.1:8082` when it is absent. +/// Invalid configuration, bind failures and server errors are returned with +/// context. FFI initialisation failure falls back to direct Rust calls. /// /// # Panics /// -/// Panics if logging or the HTTP client cannot be initialised, the address -/// cannot be bound, or the server fails. +/// Panics if logging or the HTTP client cannot be initialised. #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); + let addr = match std::env::var("ECHIDNA_GRAPHQL_ADDR") { + Ok(addr) => addr, + Err(std::env::VarError::NotPresent) => "127.0.0.1:8082".to_string(), + Err(error) => return Err(error).context("ECHIDNA_GRAPHQL_ADDR must be valid Unicode"), + }; + // Bind before initialising the schema: invalid configuration is a startup + // error, and the listener remains independent of the core server on 8081. + // TcpListener retains support for hostname overrides such as localhost:8082. + let listener = tokio::net::TcpListener::bind(&addr) + .await + .with_context(|| format!("Could not bind ECHIDNA_GRAPHQL_ADDR={addr:?}"))?; + // Create ECHIDNA context with core integration let echidna_ctx = EchidnaContext::new(); @@ -45,20 +58,13 @@ async fn main() { .route("/health", get(health_check)) .layer(CorsLayer::permissive()); - // Bind address. Default unchanged (127.0.0.1:8081) so nothing that already - // pins it breaks, but it collides with `echidna server`, whose --port also - // defaults to 8081: whichever starts second cannot bind. A deployment that - // wants both on one host sets ECHIDNA_GRAPHQL_ADDR for this one, e.g. - // ECHIDNA_GRAPHQL_ADDR=127.0.0.1:8082 echidna-graphql - // See docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc §6 RD2 and - // the one-port-per-project rule in standards 0-canon/PORT-REGISTRY.adoc. - let addr = - std::env::var("ECHIDNA_GRAPHQL_ADDR").unwrap_or_else(|_| "127.0.0.1:8081".to_string()); tracing::info!("GraphQL server listening on http://{}", addr); tracing::info!("GraphQL playground available at http://{}/", addr); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + axum::serve(listener, app) + .await + .context("GraphQL server failed")?; + Ok(()) } async fn graphql_playground() -> impl axum::response::IntoResponse { diff --git a/src/interfaces/graphql/tests/startup.rs b/src/interfaces/graphql/tests/startup.rs new file mode 100644 index 00000000..1a08f3e9 --- /dev/null +++ b/src/interfaces/graphql/tests/startup.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +//! Exercise the actual listener process, including failed startup and the root route. + +use std::net::TcpListener; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +fn server() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_echidna-graphql")); + command.stdout(Stdio::null()).stderr(Stdio::piped()); + command +} + +fn failed_start(mut command: Command) -> Output { + let mut child = command.spawn().expect("start GraphQL process"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if child.try_wait().expect("inspect GraphQL process").is_some() { + let output = child.wait_with_output().expect("read startup error"); + assert!( + !output.status.success(), + "invalid startup unexpectedly succeeded" + ); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains("ECHIDNA_GRAPHQL_ADDR"), "{error}"); + assert!(!error.contains("panicked at"), "{error}"); + return output; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("invalid startup remained running instead of returning an error"); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn malformed_address_returns_a_configuration_error() { + let mut command = server(); + command.env("ECHIDNA_GRAPHQL_ADDR", "127.0.0.1:not-a-port"); + failed_start(command); +} + +#[test] +fn occupied_address_returns_a_bind_error() { + let occupied = TcpListener::bind("127.0.0.1:0").expect("reserve a local port"); + let mut command = server(); + command.env( + "ECHIDNA_GRAPHQL_ADDR", + occupied.local_addr().unwrap().to_string(), + ); + failed_start(command); +} + +#[cfg(unix)] +#[test] +fn non_unicode_address_is_rejected_without_using_the_default() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let mut command = server(); + command.env("ECHIDNA_GRAPHQL_ADDR", OsString::from_vec(vec![0xff])); + failed_start(command); +} + +struct RunningServer(Child); + +impl Drop for RunningServer { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[tokio::test] +async fn hostname_override_serves_graphql_at_the_root() { + let reservation = TcpListener::bind("127.0.0.1:0").expect("choose a local port"); + let port = reservation.local_addr().unwrap().port(); + drop(reservation); + + let mut command = server(); + command.env("ECHIDNA_GRAPHQL_ADDR", format!("localhost:{port}")); + let mut process = RunningServer(command.spawn().expect("start GraphQL process")); + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(250)) + .build() + .unwrap(); + let base = format!("http://localhost:{port}"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(response) = client.get(format!("{base}/health")).send().await { + if response.status().is_success() { + break; + } + } + assert!( + process.0.try_wait().unwrap().is_none(), + "GraphQL process exited before serving health" + ); + assert!( + Instant::now() < deadline, + "GraphQL listener did not become ready" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + + let response: serde_json::Value = client + .post(format!("{base}/")) + .json(&serde_json::json!({"query": "{ __typename }"})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(response["data"]["__typename"], "QueryRoot"); + assert_eq!( + client + .post(format!("{base}/graphql")) + .send() + .await + .unwrap() + .status(), + reqwest::StatusCode::NOT_FOUND + ); +} From fe018f990f543ccfc47e96c9a534f51726caa0eb Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:15:40 +0000 Subject: [PATCH 08/30] fix: repair runtime security boundaries and qualify real scanner evidence Repair FFI ownership, poisoned-state handling, prover probes and parsing, IPC configuration, native arithmetic errors and workflow supply-chain boundaries. Make HTTP client construction and reconfiguration fallible through all in-tree callers, with compatibility notes. Replace placeholder static checks with revision-bound real-tool receipts that reject incomplete evidence. Adopt published scanner and governance fixes. Validation includes affected all-target checks and Clippy, real constructor failures, bounded integration/native controls, 26 evidence rejection controls and workflow lint. Detailed security-alert records are excluded from this publication. No remote alert dismissals or full native-estate qualification are claimed. --- .github/workflows/ghcr-publish.yml | 23 +- .github/workflows/governance.yml | 2 +- .github/workflows/live-provers.yml | 20 +- .github/workflows/secret-scanner.yml | 2 +- .github/workflows/server-boot-gate.yml | 15 +- .github/workflows/static-analysis-gate.yml | 540 +++++------------- crates/typed_wasm/src/lib.rs | 36 +- docs/HTTP-CLIENT-COMPATIBILITY.adoc | 52 ++ docs/SSG_ROADMAP.adoc | 251 +++----- scripts/ci/run-live-prover.sh | 2 + scripts/ci/static-analysis-evidence.sh | 249 ++++++++ scripts/ci/test-static-analysis-evidence.sh | 165 ++++++ src/interfaces/graphql/main.rs | 9 +- src/interfaces/graphql/resolvers.rs | 31 +- src/interfaces/graphql/schema.rs | 6 +- src/interfaces/rest/main.rs | 20 +- src/rust/coprocessor/flint.rs | 56 +- src/rust/coprocessor/julia_bridge.rs | 47 +- src/rust/coprocessor/parigp.rs | 11 +- src/rust/dispatch.rs | 66 ++- src/rust/fault_tolerance/resilience.rs | 93 ++- src/rust/ffi/mod.rs | 167 ++++-- src/rust/ffi/spark_axiom.rs | 2 + src/rust/gnn/client.rs | 88 ++- src/rust/gnn/guided_search.rs | 27 +- src/rust/ipc/mod.rs | 31 +- src/rust/llm.rs | 44 +- src/rust/main.rs | 2 +- src/rust/neural.rs | 94 ++- src/rust/provers/framac.rs | 36 +- src/rust/provers/lean.rs | 43 +- src/rust/provers/mod.rs | 15 +- src/rust/provers/opensmt.rs | 53 +- src/rust/provers/spass.rs | 82 ++- src/rust/repl/diagnostics.rs | 11 +- src/rust/server.rs | 17 +- src/rust/verification/axiom_tracker.rs | 15 +- src/zig/ffi/echidna_ffi.h | 6 +- src/zig/ffi/echidna_ffi.zig | 4 +- tests/corpus_hazard_fixtures.rs | 40 ++ tests/fallback_monitor_integration_test.rs | 2 +- tests/gnn_augment_integration.rs | 3 +- .../prove_handler_health_integration_test.rs | 38 +- tests/test_neural_integration.rs | 16 +- 44 files changed, 1680 insertions(+), 852 deletions(-) create mode 100644 docs/HTTP-CLIENT-COMPATIBILITY.adoc create mode 100755 scripts/ci/static-analysis-evidence.sh create mode 100755 scripts/ci/test-static-analysis-evidence.sh create mode 100644 tests/corpus_hazard_fixtures.rs diff --git a/.github/workflows/ghcr-publish.yml b/.github/workflows/ghcr-publish.yml index 46603d5a..9a9025c2 100644 --- a/.github/workflows/ghcr-publish.yml +++ b/.github/workflows/ghcr-publish.yml @@ -47,17 +47,21 @@ jobs: # Install nerdctl full (includes containerd, CNI plugins, buildkit) NERDCTL_VERSION=2.2.1 curl -fsSL "https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-full-${NERDCTL_VERSION}-linux-amd64.tar.gz" -o /tmp/nerdctl.tar.gz + # Official v2.2.1 SHA256SUMS; verify before privileged extraction. + echo 'cf4720a290f098f1a66d34a1b2e1d3736c9014fceca737861fb7a069c66c01c2 /tmp/nerdctl.tar.gz' | sha256sum --check --strict sudo tar -xzf /tmp/nerdctl.tar.gz -C /usr/local sudo mkdir -p /opt/cni/bin - sudo cp /usr/local/libexec/cni/* /opt/cni/bin/ 2>/dev/null || true + sudo cp /usr/local/libexec/cni/* /opt/cni/bin/ # Start buildkitd daemon sudo /usr/local/bin/buildkitd & sleep 3 - name: Log in to GHCR + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | sudo nerdctl login ghcr.io -u ${{ github.actor }} --password-stdin + printf '%s' "$GHCR_TOKEN" | sudo nerdctl login ghcr.io -u "$GITHUB_ACTOR" --password-stdin - name: Build minimal image run: | @@ -70,6 +74,8 @@ jobs: - name: Push images id: push + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | sudo nerdctl push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} sudo nerdctl push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest @@ -79,10 +85,10 @@ jobs: # ubuntu-latest). The minimal (:sha/:latest) and :full images are # distinct artifacts, so each gets its own attestation. MIN_DIGEST="$(skopeo inspect --format '{{.Digest}}' \ - --creds "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" \ + --creds "$GITHUB_ACTOR:$GHCR_TOKEN" \ docker://${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }})" FULL_DIGEST="$(skopeo inspect --format '{{.Digest}}' \ - --creds "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" \ + --creds "$GITHUB_ACTOR:$GHCR_TOKEN" \ docker://${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:full)" echo "min_digest=$MIN_DIGEST" >> "$GITHUB_OUTPUT" echo "full_digest=$FULL_DIGEST" >> "$GITHUB_OUTPUT" @@ -107,7 +113,10 @@ jobs: - name: Tag release version if: github.event_name == 'release' + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - VERSION=${{ github.event.release.tag_name }} - sudo nerdctl tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION} - sudo nerdctl push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION} + [[ "$RELEASE_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] \ + || { echo 'Invalid container release tag' >&2; exit 1; } + sudo nerdctl tag "$REGISTRY/$IMAGE_NAME:$GITHUB_SHA" "$REGISTRY/$IMAGE_NAME:$RELEASE_TAG" + sudo nerdctl push "$REGISTRY/$IMAGE_NAME:$RELEASE_TAG" diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index a18624ab..9772bd80 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -18,4 +18,4 @@ permissions: jobs: governance: - uses: metadatastician/standards/.github/workflows/governance-reusable.yml@d3e4b485a2af90c92a8e18af0888cd19cb132cd9 # published source, 2026-11-04 + uses: metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # published source, 2026-11-04 diff --git a/.github/workflows/live-provers.yml b/.github/workflows/live-provers.yml index 5ec8ab32..0903a499 100644 --- a/.github/workflows/live-provers.yml +++ b/.github/workflows/live-provers.yml @@ -491,6 +491,20 @@ jobs: echo "See: https://github.com/elliotttate/faial" ;; esac - - name: Run live version check (SKIP if binary absent) - continue-on-error: true # allow-fail: issue=#416 expires=2026-11-30 - run: cargo test --test live_prover_suite --features live-provers -- --nocapture live_${{ matrix.backend }}_version + - name: Require selected live version check + env: + LIVE_PROVER: ${{ matrix.backend }} + run: bash scripts/ci/run-live-prover.sh "$LIVE_PROVER" + - name: Record selected GPU capability + if: always() + env: + LIVE_PROVER: ${{ matrix.backend }} + GPU_JOB_STATUS: ${{ job.status }} + run: | + { + echo "## Selected GPU backend: $LIVE_PROVER" + echo "Execution status: $GPU_JOB_STATUS" + echo 'Missing tools mean NOT RUN, never a passed version check.' + echo 'The temporary job-level allow-fail is issue #416, expires 2026-11-30.' + echo 'A passed version probe establishes executable identity, not proof correctness.' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 567570df..8398bf6a 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -23,4 +23,4 @@ jobs: # caller must grant at least that or the run startup-fails. permissions: contents: read - uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@52a90c24bf183437b887dd182c5a9406af02c5dd # published source, 2026-11-04 + uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@8d94c37463ccdba24810da488364a7c3f82aea37 # published source, 2026-11-04 diff --git a/.github/workflows/server-boot-gate.yml b/.github/workflows/server-boot-gate.yml index e9267c75..670d25ef 100644 --- a/.github/workflows/server-boot-gate.yml +++ b/.github/workflows/server-boot-gate.yml @@ -36,7 +36,7 @@ jobs: - name: Install system dependencies run: sudo apt-get install -y libssl-dev pkg-config - name: Build echidna binary - run: cargo build -p echidna + run: cargo build --locked -p echidna - name: Boot server and verify routes run: | ./target/debug/echidna server --port 8081 & @@ -63,13 +63,12 @@ jobs: -d '{"prover":"Z3"}') || { echo "FAIL: session/create"; exit 1; } echo "$SESSION" - SESSION_ID=$(echo "$SESSION" | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4) - if [ -n "$SESSION_ID" ]; then - echo "" - echo "=== GET /api/session/{id}/state ===" - curl -sf "http://127.0.0.1:8081/api/session/${SESSION_ID}/state" \ - || { echo "FAIL: session/{id}/state"; exit 1; } - fi + SESSION_ID=$(jq -er '.session_id | select(type == "string" and length > 0)' <<< "$SESSION") \ + || { echo "FAIL: session/create returned no usable session_id"; exit 1; } + echo "" + echo "=== GET /api/session/{id}/state ===" + curl -sf "http://127.0.0.1:8081/api/session/${SESSION_ID}/state" \ + || { echo "FAIL: session/{id}/state"; exit 1; } echo "" echo "Boot gate PASSED" diff --git a/.github/workflows/static-analysis-gate.yml b/.github/workflows/static-analysis-gate.yml index 1f34ab8e..dfc93031 100644 --- a/.github/workflows/static-analysis-gate.yml +++ b/.github/workflows/static-analysis-gate.yml @@ -1,7 +1,7 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -# Static Analysis Gate — Required by branch protection rules. -# Runs panic-attack and hypatia, deposits findings for gitbot-fleet learning. +# Keep these job names: branch protection requires their existing contexts. +# Artifacts are scan observations, not verified repairs or training receipts. name: Static Analysis Gate on: pull_request: @@ -13,362 +13,160 @@ concurrency: cancel-in-progress: true permissions: contents: read +env: + # The vetted scanner pins from standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9. + HYPATIA_REF: adb2f41db34156cf81b2508f6596a10c7f076098 + PANIC_ATTACK_REF: 410a8f821ea8297833162565b50a298f787815da + SCAN_EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} jobs: - # --------------------------------------------------------------------------- - # Job 1: panic-attack assail - # --------------------------------------------------------------------------- panic-attack-assail: name: panic-attack assail runs-on: ubuntu-24.04 - timeout-minutes: 15 + # Ten-minute cold build + five-minute scan, with time to preserve evidence. + timeout-minutes: 20 steps: - name: Checkout repository uses: actions/checkout@v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - - name: Install panic-attack (if available) - id: install - run: | - # Try to fetch the latest release binary from the org - PA_URL="https://github.com/hyperpolymath/panic-attack/releases/latest/download/panic-attack-linux-x86_64" - if curl -fsSL --head "$PA_URL" >/dev/null 2>&1; then - curl -fsSL -o /usr/local/bin/panic-attack "$PA_URL" - chmod +x /usr/local/bin/panic-attack - echo "installed=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::panic-attack binary not available — skipping assail" - echo "installed=false" >> "$GITHUB_OUTPUT" - fi + persist-credentials: false + - name: Verify scanner receipt controls + run: bash scripts/ci/test-static-analysis-evidence.sh + - name: Build pinned panic-attack with Patch Bridge support + run: | + set -euo pipefail + tool_root="$RUNNER_TEMP/panic-toolchain" + # The canonical build defaults to no optional features. Bridge is + # compiled only with http; build once and reuse this exact binary. + timeout 600 cargo install --git https://github.com/hyperpolymath/panic-attack \ + --rev "$PANIC_ATTACK_REF" --locked --features http --root "$tool_root" panic-attack + grep -Fq "#$PANIC_ATTACK_REF" "$tool_root/.crates.toml" + "$tool_root/bin/panic-attack" --version + "$tool_root/bin/panic-attack" bridge triage --help + jq -cn --arg sha "$PANIC_ATTACK_REF" \ + --arg digest "$(sha256sum "$tool_root/bin/panic-attack" | cut -d' ' -f1)" \ + --arg rust "$(rustc --version)" --arg cargo "$(cargo --version)" \ + '{source_sha:$sha,binary_sha256:$digest,features:["http"],rustc:$rust,cargo:$cargo}' \ + > "$tool_root/source.json" + - name: Preserve the pinned scanner for Patch Bridge + uses: actions/upload-artifact@v7.0.2 + with: + name: panic-attack-toolchain + path: | + ${{ runner.temp }}/panic-toolchain/bin/panic-attack + ${{ runner.temp }}/panic-toolchain/source.json + if-no-files-found: error + retention-days: 7 - name: Run panic-attack assail - id: assail - if: steps.install.outputs.installed == 'true' + if: always() run: | - set +e - panic-attack assail --format json . > panic-attack-findings.json - PA_EXIT=$? - set -e - - # Same defect class as the Hypatia job below: `2>&1` folded the - # scanner's stderr into the JSON payload, so every jq parse failed, - # every count silently became 0 via `|| echo 0`, and "Fail on critical - # findings" could never fire on any input. Keep stderr on the log. - if [ ! -s panic-attack-findings.json ]; then - echo "[]" > panic-attack-findings.json - fi - - # Deliberately a WARNING, not a failure. panic-attack is a downloaded - # release binary whose exit-code and output contract are not verified - # here, and it has no confirmed --exit-zero equivalent, so we surface a - # malformed payload in the log rather than block on an unverified tool. - # Promote to `exit 1` (as the Hypatia job does) once that contract is - # confirmed -- see the follow-up issue linked from this PR. - if ! jq -e 'type == "array"' panic-attack-findings.json >/dev/null 2>&1; then - echo "::warning::panic-attack output is not a JSON array (exit ${PA_EXIT}); counts below are unreliable" - fi - - # Parse finding counts - TOTAL=$(jq '. | length' panic-attack-findings.json 2>/dev/null || echo 0) - CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' panic-attack-findings.json 2>/dev/null || echo 0) - HIGH=$(jq '[.[] | select(.severity == "high")] | length' panic-attack-findings.json 2>/dev/null || echo 0) - MEDIUM=$(jq '[.[] | select(.severity == "medium" or .severity == "warn")] | length' panic-attack-findings.json 2>/dev/null || echo 0) - LOW=$(jq '[.[] | select(.severity == "low")] | length' panic-attack-findings.json 2>/dev/null || echo 0) - - echo "total=$TOTAL" >> "$GITHUB_OUTPUT" - echo "critical=$CRITICAL" >> "$GITHUB_OUTPUT" - echo "high=$HIGH" >> "$GITHUB_OUTPUT" - echo "medium=$MEDIUM" >> "$GITHUB_OUTPUT" - echo "low=$LOW" >> "$GITHUB_OUTPUT" - echo "exit_code=$PA_EXIT" >> "$GITHUB_OUTPUT" - - name: Emit check annotations - if: steps.install.outputs.installed == 'true' - run: | - # Convert JSON findings into GitHub Actions annotations - # Findings carry no `.message` (keys: action,file,line,reason,rule_module, - # severity,type), so every annotation read "null". `.file` is an absolute - # runner path, which GitHub cannot anchor to the diff, so it is made - # workspace-relative here. - # Annotations are cosmetic, but a malformed findings file is reported - # instead of being swallowed. - if jq -e 'type == "array"' panic-attack-findings.json >/dev/null 2>&1; then - jq -r --arg ws "$GITHUB_WORKSPACE" '.[] | select(.file != null) | - (.file | ltrimstr($ws + "/")) as $f | - (.reason // .message // .type // "finding") as $m | - if .severity == "critical" then - "::error file=\($f),line=\(.line // 1)::[panic-attack] \($m)" - elif .severity == "high" then - "::error file=\($f),line=\(.line // 1)::[panic-attack] \($m)" - else - "::warning file=\($f),line=\(.line // 1)::[panic-attack] \($m)" - end - ' panic-attack-findings.json - else - echo "::warning::panic-attack-findings.json is not a JSON array; no annotations emitted" - fi - - name: Write step summary - if: steps.install.outputs.installed == 'true' - run: | - cat <> "$GITHUB_STEP_SUMMARY" - ## panic-attack assail Results - - | Severity | Count | - |----------|-------| - | Critical | ${{ steps.assail.outputs.critical }} | - | High | ${{ steps.assail.outputs.high }} | - | Medium | ${{ steps.assail.outputs.medium }} | - | Low | ${{ steps.assail.outputs.low }} | - | **Total**| ${{ steps.assail.outputs.total }} | - EOF - - name: Create stub findings (when panic-attack unavailable) - if: steps.install.outputs.installed != 'true' - run: | - echo "[]" > panic-attack-findings.json - echo "## panic-attack assail" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Skipped: panic-attack not available in this environment." >> "$GITHUB_STEP_SUMMARY" - - name: Upload panic-attack findings + bash scripts/ci/static-analysis-evidence.sh scan panic-attack "$PANIC_ATTACK_REF" \ + "$RUNNER_TEMP/panic-toolchain/bin/panic-attack" "$RUNNER_TEMP/panic-toolchain/source.json" \ + "$RUNNER_TEMP/panic-attack-findings" + - name: Upload panic-attack findings and completion receipt + if: always() uses: actions/upload-artifact@v7.0.2 with: name: panic-attack-findings - path: panic-attack-findings.json + path: ${{ runner.temp }}/panic-attack-findings/ + if-no-files-found: error retention-days: 90 - - name: Fail on critical findings - if: steps.install.outputs.installed == 'true' && steps.assail.outputs.critical > 0 - run: | - echo "::error::panic-attack found ${{ steps.assail.outputs.critical }} critical issue(s) — blocking merge" - exit 1 - # --------------------------------------------------------------------------- - # Job 2: hypatia-scan - # - # NOTE — this is NOT a duplicate of the standalone `hypatia-scan.yml`. - # * THIS job runs Hypatia inside the gate and hands its findings to the - # `deposit-findings` job below, which feeds the gitbot-fleet LEARNING - # pipeline (artifact -> fleet). It is a REQUIRED status check. - # * `hypatia-scan.yml` runs Hypatia standalone as the repo's security scan, - # independent of the fleet-learning deposit. - # Same tool, two different consumers — keep both. See .github/workflows/README.adoc. - # --------------------------------------------------------------------------- + - name: Require completed scan and reject critical findings + if: always() + run: bash scripts/ci/static-analysis-evidence.sh gate "$RUNNER_TEMP/panic-attack-findings/receipt.json" + hypatia-scan: name: Hypatia neurosymbolic scan runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 steps: - name: Checkout repository uses: actions/checkout@v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + persist-credentials: false - name: Setup Elixir for Hypatia scanner - id: beam - continue-on-error: true # allow-fail: issue=#416 expires=2026-11-30 uses: erlef/setup-beam@v1.24.1 with: elixir-version: '1.19.4' otp-version: '28.3' - - name: Clone and build Hypatia - id: build - continue-on-error: true # allow-fail: issue=#416 expires=2026-11-30 - env: - # Pinned to the hypatia commit standards@5751b97e vets as its default. - # When the standards callers move to that ref, the governance baseline - # job and this gate build the same scanner; move them together. - HYPATIA_REF: 51ab6496bf47e30a0576d503df31fec30f3cfe56 - run: | - git init -q "$HOME/hypatia" \ - && git -C "$HOME/hypatia" fetch -q --depth 1 https://github.com/hyperpolymath/hypatia.git "$HYPATIA_REF" \ - && git -C "$HOME/hypatia" checkout -q FETCH_HEAD \ - || true - if [ -f "$HOME/hypatia/mix.exs" ]; then - cd "$HOME/hypatia" - # Build escript if neither hypatia nor hypatia-v2 exists - if [ ! -f hypatia ] && [ ! -f hypatia-v2 ]; then - mix deps.get - mix escript.build - fi - echo "ready=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::Hypatia scanner not available — skipping scan" - echo "ready=false" >> "$GITHUB_OUTPUT" - fi + - name: Build pinned Hypatia + run: | + set -euo pipefail + scanner="$RUNNER_TEMP/hypatia" + git init -q "$scanner" + timeout 60 git -C "$scanner" fetch -q --depth 1 https://github.com/hyperpolymath/hypatia.git "$HYPATIA_REF" + git -C "$scanner" checkout -q --detach FETCH_HEAD + test "$(git -C "$scanner" rev-parse HEAD)" = "$HYPATIA_REF" + ( + cd "$scanner" + timeout 600 bash -c 'set -euo pipefail; mix deps.get; mix escript.build' + ) + test -x "$scanner/hypatia" + jq -cn --arg sha "$(git -C "$scanner" rev-parse HEAD)" \ + --arg digest "$(sha256sum "$scanner/hypatia" | cut -d' ' -f1)" \ + '{source_sha:$sha,binary_sha256:$digest}' > "$scanner/source.json" - name: Run Hypatia scan - id: scan - if: steps.build.outputs.ready == 'true' - run: | - set +e - HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json - HYP_EXIT=$? - set -e - - # --exit-zero is Hypatia's own documented CI recipe (lib/hypatia/cli.ex), - # for exactly this case: "use in CI when a downstream step gates on - # severity counts". Findings go to stdout, the one-line summary to - # stderr, and the process exits 0 unless the SCANNER itself failed. - # - # Do NOT redirect stderr into the payload with `2>&1`: that folds the - # summary line into the JSON, so every parse fails, the old `[]` - # fallback substituted a clean result, CRITICAL was always 0, and the - # gate below could never fire on any input. Keep stderr on the log. - if [ "$HYP_EXIT" -ne 0 ]; then - echo "::error::Hypatia scanner execution failed with exit ${HYP_EXIT}" - exit "$HYP_EXIT" - fi - # `jq empty` is NOT sufficient -- it succeeds on any valid JSON, - # including a bare string, object or null. Assert the array. - if [ ! -s hypatia-findings.json ] || ! jq -e 'type == "array"' hypatia-findings.json >/dev/null; then - echo "::error::Hypatia did not produce a valid JSON findings array" - exit 1 - fi - - TOTAL=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) - CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json 2>/dev/null || echo 0) - HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json 2>/dev/null || echo 0) - MEDIUM=$(jq '[.[] | select(.severity == "medium" or .severity == "warn")] | length' hypatia-findings.json 2>/dev/null || echo 0) - LOW=$(jq '[.[] | select(.severity == "low")] | length' hypatia-findings.json 2>/dev/null || echo 0) - - echo "total=$TOTAL" >> "$GITHUB_OUTPUT" - echo "critical=$CRITICAL" >> "$GITHUB_OUTPUT" - echo "high=$HIGH" >> "$GITHUB_OUTPUT" - echo "medium=$MEDIUM" >> "$GITHUB_OUTPUT" - echo "low=$LOW" >> "$GITHUB_OUTPUT" - - name: Emit check annotations - if: steps.build.outputs.ready == 'true' - run: | - # Findings carry no `.message` (keys: action,file,line,reason,rule_module, - # severity,type), so every annotation read "null". `.file` is an absolute - # runner path, which GitHub cannot anchor to the diff, so it is made - # workspace-relative here. - # Annotations are cosmetic, but a malformed findings file is reported - # instead of being swallowed. - if jq -e 'type == "array"' hypatia-findings.json >/dev/null 2>&1; then - jq -r --arg ws "$GITHUB_WORKSPACE" '.[] | select(.file != null) | - (.file | ltrimstr($ws + "/")) as $f | - (.reason // .message // .type // "finding") as $m | - if .severity == "critical" then - "::error file=\($f),line=\(.line // 1)::[hypatia] \($m)" - elif .severity == "high" then - "::error file=\($f),line=\(.line // 1)::[hypatia] \($m)" - else - "::warning file=\($f),line=\(.line // 1)::[hypatia] \($m)" - end - ' hypatia-findings.json - else - echo "::warning::hypatia-findings.json is not a JSON array; no annotations emitted" - fi - - name: Write step summary - if: steps.build.outputs.ready == 'true' - run: | - cat <> "$GITHUB_STEP_SUMMARY" - ## Hypatia Scan Results - - | Severity | Count | - |----------|-------| - | Critical | ${{ steps.scan.outputs.critical }} | - | High | ${{ steps.scan.outputs.high }} | - | Medium | ${{ steps.scan.outputs.medium }} | - | Low | ${{ steps.scan.outputs.low }} | - | **Total**| ${{ steps.scan.outputs.total }} | - EOF - - name: Create stub findings (when Hypatia unavailable) - if: steps.build.outputs.ready != 'true' + if: always() run: | - echo "[]" > hypatia-findings.json - echo "## Hypatia Scan" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Skipped: Hypatia scanner not available in this environment." >> "$GITHUB_STEP_SUMMARY" - - name: Upload hypatia findings + bash scripts/ci/static-analysis-evidence.sh scan hypatia "$HYPATIA_REF" \ + "$RUNNER_TEMP/hypatia/hypatia" "$RUNNER_TEMP/hypatia/source.json" \ + "$RUNNER_TEMP/hypatia-findings" + - name: Upload Hypatia findings and completion receipt + if: always() uses: actions/upload-artifact@v7.0.2 with: name: hypatia-findings - path: hypatia-findings.json + path: ${{ runner.temp }}/hypatia-findings/ + if-no-files-found: error retention-days: 90 - - name: Fail on critical security findings - if: steps.build.outputs.ready == 'true' && steps.scan.outputs.critical > 0 - run: | - echo "::error::Hypatia found ${{ steps.scan.outputs.critical }} critical security issue(s) — blocking merge" - exit 1 - # --------------------------------------------------------------------------- - # Job 3: patch-bridge triage (CVE contextual assessment) - # --------------------------------------------------------------------------- + - name: Require completed scan and reject critical findings + if: always() + run: bash scripts/ci/static-analysis-evidence.sh gate "$RUNNER_TEMP/hypatia-findings/receipt.json" + patch-bridge-triage: name: Patch Bridge CVE triage runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 10 + needs: panic-attack-assail + # A critical assail finding must not prevent CVE triage or its receipt. + if: always() steps: - name: Checkout repository uses: actions/checkout@v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - - name: Install panic-attack (if available) - id: install - run: | - PA_URL="https://github.com/hyperpolymath/panic-attack/releases/latest/download/panic-attack-linux-x86_64" - if curl -fsSL --head "$PA_URL" >/dev/null 2>&1; then - curl -fsSL -o /usr/local/bin/panic-attack "$PA_URL" - chmod +x /usr/local/bin/panic-attack - echo "installed=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::panic-attack binary not available — skipping Patch Bridge" - echo "installed=false" >> "$GITHUB_OUTPUT" - fi + persist-credentials: false + - name: Download the exact pinned panic-attack build + uses: actions/download-artifact@v8.0.2 + with: + name: panic-attack-toolchain + path: ${{ runner.temp }}/panic-toolchain/ - name: Run Patch Bridge triage - id: triage - if: steps.install.outputs.installed == 'true' - run: | - set +e - panic-attack bridge triage --format json . > bridge-report.json 2>&1 - PB_EXIT=$? - set -e - - if [ ! -s bridge-report.json ] || ! jq empty bridge-report.json 2>/dev/null; then - echo '{"cves":[],"mitigated":0,"unmitigable":0,"concatenative":0,"informational":0}' > bridge-report.json - fi - - UNMITIGABLE=$(jq '.unmitigable // 0' bridge-report.json) - MITIGATED=$(jq '.mitigated // 0' bridge-report.json) - CONCATENATIVE=$(jq '.concatenative // 0' bridge-report.json) - INFORMATIONAL=$(jq '.informational // 0' bridge-report.json) - - echo "unmitigable=$UNMITIGABLE" >> "$GITHUB_OUTPUT" - echo "mitigated=$MITIGATED" >> "$GITHUB_OUTPUT" - echo "concatenative=$CONCATENATIVE" >> "$GITHUB_OUTPUT" - echo "informational=$INFORMATIONAL" >> "$GITHUB_OUTPUT" - - name: Write step summary - if: steps.install.outputs.installed == 'true' - run: | - cat <> "$GITHUB_STEP_SUMMARY" - ## Patch Bridge CVE Triage - - | Classification | Count | - |----------------|-------| - | Unmitigable | ${{ steps.triage.outputs.unmitigable }} | - | Mitigated | ${{ steps.triage.outputs.mitigated }} | - | Concatenative | ${{ steps.triage.outputs.concatenative }} | - | Informational | ${{ steps.triage.outputs.informational }} | - - Unmitigable CVEs require dependency replacement or rearchitecture. - Mitigated CVEs have active controls with soundness proofs. - Concatenative risks are CVE combinations that multiply severity. - EOF - - name: Create stub report (when unavailable) - if: steps.install.outputs.installed != 'true' - run: | - echo '{"cves":[],"mitigated":0,"unmitigable":0,"concatenative":0,"informational":0}' > bridge-report.json - echo "## Patch Bridge CVE Triage" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Skipped: panic-attack not available in this environment." >> "$GITHUB_STEP_SUMMARY" - - name: Upload bridge report + if: always() + run: | + # Artifact transfer does not retain executable bits; the scan adapter + # verifies the binary digest against the build receipt before use. + binary="$RUNNER_TEMP/panic-toolchain/bin/panic-attack" + if [ -f "$binary" ]; then chmod +x "$binary"; fi + bash scripts/ci/static-analysis-evidence.sh scan patch-bridge "$PANIC_ATTACK_REF" \ + "$binary" "$RUNNER_TEMP/panic-toolchain/source.json" "$RUNNER_TEMP/bridge-report" + - name: Upload bridge report and completion receipt + if: always() uses: actions/upload-artifact@v7.0.2 with: name: bridge-report - path: bridge-report.json + path: ${{ runner.temp }}/bridge-report/ + if-no-files-found: error retention-days: 90 - - name: Fail on unmitigable CVEs in critical paths - if: steps.install.outputs.installed == 'true' && steps.triage.outputs.unmitigable > 0 - run: | - echo "::warning::Patch Bridge found ${{ steps.triage.outputs.unmitigable }} unmitigable CVE(s) — review required" - # Warning only, not blocking. Unmitigable means the developer needs - # to make an architectural decision, not that the PR is wrong. - # --------------------------------------------------------------------------- - # Job 4: deposit-findings (combines + archives for gitbot-fleet) - # --------------------------------------------------------------------------- + - name: Require completed triage and report unmitigable CVEs + if: always() + # Classification remains advisory; execution failure is not a clean CVE result. + run: bash scripts/ci/static-analysis-evidence.sh gate "$RUNNER_TEMP/bridge-report/receipt.json" + deposit-findings: name: Deposit findings for gitbot-fleet runs-on: ubuntu-24.04 @@ -376,99 +174,59 @@ jobs: needs: [panic-attack-assail, hypatia-scan, patch-bridge-triage] if: always() steps: - - name: Download panic-attack findings + - name: Checkout the scanned revision + uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Download panic-attack evidence + if: always() uses: actions/download-artifact@v8.0.2 with: name: panic-attack-findings - path: findings/ - - name: Download hypatia findings + path: findings/panic-attack-findings/ + - name: Download Hypatia evidence + if: always() uses: actions/download-artifact@v8.0.2 with: name: hypatia-findings - path: findings/ - - name: Download bridge report + path: findings/hypatia-findings/ + - name: Download Patch Bridge evidence + if: always() uses: actions/download-artifact@v8.0.2 with: name: bridge-report - path: findings/ - - name: Combine findings into unified report + path: findings/bridge-report/ + - name: Validate completion and combine observations id: combine - run: | - PA_FILE="findings/panic-attack-findings.json" - HYP_FILE="findings/hypatia-findings.json" - - # Ensure both files exist and are valid JSON arrays - for f in "$PA_FILE" "$HYP_FILE"; do - if [ ! -s "$f" ] || ! jq empty "$f" 2>/dev/null; then - echo "[]" > "$f" - fi - done - - # Tag each finding with its source scanner - jq '[.[] | . + {"scanner": "panic-attack"}]' "$PA_FILE" > /tmp/pa-tagged.json - jq '[.[] | . + {"scanner": "hypatia"}]' "$HYP_FILE" > /tmp/hyp-tagged.json - - # Read bridge report (CVE triage, not findings array) - BRIDGE_FILE="findings/bridge-report.json" - if [ ! -s "$BRIDGE_FILE" ] || ! jq empty "$BRIDGE_FILE" 2>/dev/null; then - echo '{"cves":[],"mitigated":0,"unmitigable":0,"concatenative":0,"informational":0}' > "$BRIDGE_FILE" - fi - - # Build unified report envelope - jq -n \ - --arg repo "${{ github.repository }}" \ - --arg sha "${{ github.sha }}" \ - --arg ref "${{ github.ref }}" \ - --arg run_id "${{ github.run_id }}" \ - --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --slurpfile pa /tmp/pa-tagged.json \ - --slurpfile hyp /tmp/hyp-tagged.json \ - --slurpfile bridge "$BRIDGE_FILE" \ - '{ - schema_version: "1.1.0", - repository: $repo, - commit_sha: $sha, - ref: $ref, - run_id: $run_id, - timestamp: $ts, - findings: ($pa[0] + $hyp[0]), - patch_bridge: $bridge[0] - }' > findings/unified-findings.json - - TOTAL=$(jq '.findings | length' findings/unified-findings.json) - CRITICAL=$(jq '[.findings[] | select(.severity == "critical")] | length' findings/unified-findings.json) - HIGH=$(jq '[.findings[] | select(.severity == "high")] | length' findings/unified-findings.json) - MEDIUM=$(jq '[.findings[] | select(.severity == "medium" or .severity == "warn")] | length' findings/unified-findings.json) - LOW=$(jq '[.findings[] | select(.severity == "low")] | length' findings/unified-findings.json) - - echo "total=$TOTAL" >> "$GITHUB_OUTPUT" - echo "critical=$CRITICAL" >> "$GITHUB_OUTPUT" - echo "high=$HIGH" >> "$GITHUB_OUTPUT" - echo "medium=$MEDIUM" >> "$GITHUB_OUTPUT" - echo "low=$LOW" >> "$GITHUB_OUTPUT" - - name: Upload unified findings (fleet scanner picks these up) + if: always() + run: bash scripts/ci/static-analysis-evidence.sh deposit findings + - name: Preserve raw evidence and deposit status + if: always() + uses: actions/upload-artifact@v7.0.2 + with: + name: static-analysis-evidence + path: findings/ + if-no-files-found: error + retention-days: 90 + - name: Upload complete unified findings + if: steps.combine.outcome == 'success' uses: actions/upload-artifact@v7.0.2 with: name: unified-findings path: findings/unified-findings.json + if-no-files-found: error retention-days: 90 - name: Write deposit summary - run: | - cat <> "$GITHUB_STEP_SUMMARY" - ## Unified Findings Deposit - - **Repository:** ${{ github.repository }} - **Commit:** \`${{ github.sha }}\` - **Deposited at:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") - - | Severity | Count | - |----------|-------| - | Critical | ${{ steps.combine.outputs.critical }} | - | High | ${{ steps.combine.outputs.high }} | - | Medium | ${{ steps.combine.outputs.medium }} | - | Low | ${{ steps.combine.outputs.low }} | - | **Total**| ${{ steps.combine.outputs.total }} | - - Findings saved as \`unified-findings\` artifact. - The gitbot-fleet scanner will ingest these on its next pass. - EOF + if: always() + run: | + set -euo pipefail + { + echo '## Static analysis evidence deposit' + echo '' + jq -r '"Complete scanner evidence: **\(.complete)**\n", (.scanners[] | "- \(.tool): \(.status)")' findings/deposit-status.json + echo '' + echo 'Raw reports and completion receipts are archived in static-analysis-evidence.' + echo 'unified-findings is produced only when every receipt matches this source revision and run.' + echo 'These are observations. Fleet ingestion and verified repair learning are not confirmed by this upload.' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/crates/typed_wasm/src/lib.rs b/crates/typed_wasm/src/lib.rs index 0885e87f..b0a34670 100644 --- a/crates/typed_wasm/src/lib.rs +++ b/crates/typed_wasm/src/lib.rs @@ -336,6 +336,11 @@ pub fn parse_twasm(content: &str) -> Result> { let brace_close = line .rfind('}') .ok_or_else(|| anyhow!("Missing '}}' in region def"))?; + if brace_close <= brace_open { + return Err(anyhow!( + "Region closing brace must follow its opening brace" + )); + } let fields_str = &line[brace_open + 1..brace_close]; let fields = parse_fields(fields_str)?; @@ -362,16 +367,22 @@ pub fn parse_twasm(content: &str) -> Result> { if line.starts_with("module ") { let tokens: Vec<&str> = line.split_whitespace().collect(); if tokens.len() >= 4 && tokens[2] == "export" && tokens[3] == "region" { + let region = tokens + .get(4) + .ok_or_else(|| anyhow!("Missing region name in module export"))?; instructions.push(TwasmInstruction::ModuleExport { module_name: tokens[1].to_string(), - region_name: tokens[4].to_string(), + region_name: region.to_string(), }); continue; } if tokens.len() >= 4 && tokens[2] == "import" && tokens[3] == "region" { + let region = tokens + .get(4) + .ok_or_else(|| anyhow!("Missing region name in module import"))?; instructions.push(TwasmInstruction::ModuleImport { module_name: tokens[1].to_string(), - region_name: tokens[4].to_string(), + region_name: region.to_string(), }); continue; } @@ -418,11 +429,18 @@ pub fn parse_twasm(content: &str) -> Result> { if line.starts_with("effect ") && line.contains('{') { let rest = &line["effect ".len()..]; - let brace_open = rest.find('{').unwrap(); + let brace_open = rest + .find('{') + .ok_or_else(|| anyhow!("Missing '{{' in effect"))?; let region = rest[..brace_open].trim().to_string(); let brace_close = rest .rfind('}') .ok_or_else(|| anyhow!("Missing '}}' in effect"))?; + if brace_close <= brace_open { + return Err(anyhow!( + "Effect closing brace must follow its opening brace" + )); + } let effects_str = &rest[brace_open + 1..brace_close]; let effects: Vec = effects_str .split(';') @@ -957,6 +975,18 @@ region.get Small[99] .val assert!(parse_twasm(bad).is_err()); } + #[test] + fn test_malformed_delimiters_and_module_names_return_errors() { + for malformed in [ + "region Foo } {", + "effect Foo } {", + "module Demo import region", + "module Demo export region", + ] { + assert!(parse_twasm(malformed).is_err(), "{malformed}"); + } + } + #[test] fn test_level2_field_not_found() { let instructions = parse_twasm(BAD_FIELD_TWASM).unwrap(); diff --git a/docs/HTTP-CLIENT-COMPATIBILITY.adoc b/docs/HTTP-CLIENT-COMPATIBILITY.adoc new file mode 100644 index 00000000..90fb2183 --- /dev/null +++ b/docs/HTTP-CLIENT-COMPATIBILITY.adoc @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later += Fallible HTTP client construction + +HTTP transport construction returns errors through the Julia coprocessor bridge, +GNN client, neural suggester, LLM advisor, guided search and API startup paths. +Callers must propagate or handle these errors. + +[cols="2,3",options="header"] +|=== +|API |Caller handling +|GnnClient, NeuralSuggester, LlmAdvisor, GnnGuidedSearch +|new and with_config return anyhow::Result. +|JuliaCoprocessorBridge::new +|Returns anyhow::Result; successful construction does not establish health. +|GnnClient::set_config, NeuralSuggester::set_config +|Return anyhow::Result<()>. Failure preserves the previous client, configuration +and health. Success requires a new health probe before inference. +|Runtime Default implementations for the first four types +|Removed because construction can fail. Use new()? or explicit error handling. +Configuration structs retain Default. +|=== + +GraphQL context construction and REST/GraphQL startup propagate HTTP setup +errors. Optional GNN augmentation logs setup failure and retains bounded +heuristic suggestions without claiming GNN execution. + +All in-tree Rust consumers were migrated. External Rust consumers need the +same error handling; this document does not establish their compatibility. + +== Qualification + +The failure controls use invalid headers with the real reqwest builder, retain +the typed builder error, and exercise atomic configuration replacement. +The bridge remains unprobed after successful construction. + +The affected core client and guided-search modules passed 30 tests; these +include seven constructor and state controls. GNN augmentation passed 99 +controlled HTTP-mock tests, fallback monitoring passed seven tests, and neural +integration passed two tests with its five existing live-Julia cases ignored. +GraphQL passed nine binary tests, including its constructor error control, and +four actual child-process listener/startup tests. + +The combined core, GraphQL and REST API source passed: + +[source,shell] +---- +cargo clippy --locked -p echidna -p echidna-graphql -p echidna-rest --all-targets -- -D warnings +---- + +These test counts overlap. Mock-service and builder-boundary results do not +establish live Julia, BoJ, model or native prover capability. Hosted checks and +the default-branch scans still determine the resulting repository status. diff --git a/docs/SSG_ROADMAP.adoc b/docs/SSG_ROADMAP.adoc index 29443b41..8a7abaf1 100644 --- a/docs/SSG_ROADMAP.adoc +++ b/docs/SSG_ROADMAP.adoc @@ -4,202 +4,117 @@ = SSG Integration Roadmap :toc: -== Overview +== Status and evidence -ECHIDNA's neurosymbolic verification capabilities extend naturally to static site generation, enabling formally verified content pipelines. This roadmap outlines three integration paths currently in development. +The three SSG integration tracks below are retained design proposals. +The 2026-11-04 source review at `977ce94d9a83b8a3a47f54a67a14d004380fcdfa` +did not find the dedicated SSG renderer, MCP property-oracle service, +SSG-specific proofs, or extracted SSG library previously described here as +complete or in progress. Those implementation claims and the elapsed +2025 delivery dates are withdrawn. This document does not establish that +any SSG property has been proved or that an SSG integration is deployed. -== Active Development Tracks +The reusable ECHIDNA components that do exist are: -=== Track 1: `echidna-docs` — Proof Content SSG - -*Status*: 🟢 Active Development - -A static site generator that understands formal proof content from 12 theorem prover systems. - -[cols="1,1,1"] +[cols="1,2",options="header"] |=== -|Component |Status |Location - -|Prover backend traits -|✅ Complete -|`src/echidna_provers.rs` - -|Agda parser integration -|✅ Complete -|`src/prover/agda.rs` - -|Aspect tagging system -|✅ Complete -|`src/aspect/` - -|HTML proof renderer -|🔨 In Progress -|`src/echidna_docs/render.rs` - -|Dependency graph generator -|🔨 In Progress -|`src/echidna_docs/graph.rs` - -|CLI interface -|📋 Planned -|`src/bin/echidna-docs.rs` +|Component |Current source +|Prover backend interface |link:../src/rust/provers/mod.rs[ProverBackend] +|Agda backend |link:../src/rust/provers/agda.rs[Agda adapter] +|Z3 backend |link:../src/rust/provers/z3.rs[Z3 adapter] +|CVC5 backend |link:../src/rust/provers/cvc5.rs[CVC5 adapter] +|Aspect representation |link:../src/rust/aspect.rs[Aspect model] |=== -*Unique Capabilities*: +Their presence is a starting point for integration. It does not supply the +missing SSG implementation or qualify an arbitrary property encoding. -* Semantic syntax highlighting (type-aware, not regex) -* Cross-prover theorem linking -* Aspect-tagged navigation -* Proof dependency visualisation +== Retained design tracks -=== Track 2: `echidna-verify` — Property Oracle MCP +=== Track 1: `echidna-docs` — proof content generation -*Status*: 🟡 Architecture Complete +*Status*: proposed; no dedicated implementation is qualified by this review. -MCP server for verifying semantic properties of SSG implementations across the polyglot-ssg ecosystem. +The intended generator would render proof-bearing documents and expose +cross-prover references, aspect navigation, and dependency graphs. +Reusable backend parsing is available; the following work still needs an +implementation and tests: -[cols="1,1,1"] -|=== -|Component |Status |Location - -|MCP tool definitions -|✅ Complete -|`src/echidna_verify/lib.rs` +* an HTML renderer that escapes content and preserves semantic structure; +* stable links between theorem identifiers and generated pages; +* a dependency graph derived from parsed proofs; +* a CLI with explicit error handling for unparsed or unsupported content; +* a supported-prover matrix established by actual fixtures and executions. -|Z3 backend integration -|✅ Complete -|`src/solver/z3.rs` +Do not infer support for a prover solely from its presence in ECHIDNA's +enum or from a successful executable version probe. -|CVC5 backend integration -|✅ Complete -|`src/solver/cvc5.rs` +=== Track 2: `echidna-verify` — property oracle -|Property encoders -|🔨 In Progress -|`src/echidna_verify/properties/` +*Status*: proposed architecture; no dedicated SSG MCP service is qualified. -|Counterexample minimiser -|📋 Planned -|`src/echidna_verify/minimize.rs` +The intended MCP tools are `verify_ssg_property`, +`compare_implementations`, and `generate_counterexample`. +Existing solver adapters can be reused, but each tool needs a specified +input language, property encoder, result contract, and tests. -|Fuzzing harness -|📋 Planned -|`src/echidna_verify/fuzz.rs` -|=== +A successful solver invocation alone is insufficient. The service must +record the exact input and encoding, selected backend and version, +timeout or unsupported result, and the evidence needed to interpret the +solver's answer. Cross-language comparisons require a shared semantics; +agreement between implementations does not establish that semantics. +Counterexample minimisation and fuzzing remain implementation work. -*MCP Tools*: +=== Track 3: `libechidna-ssg` — verified core library -* `verify_ssg_property` — Check semantic properties hold -* `compare_implementations` — Verify equivalence across languages -* `generate_counterexample` — Find inputs that distinguish implementations +*Status*: proposed specification and extraction work; no extracted library +or SSG-specific proof corpus is qualified. -=== Track 3: `libechidna-ssg` — Verified Core Library +The retained candidate properties are: -*Status*: 🔵 Specification Phase - -Formally verified SSG core with proofs extracted to a shared native library. - -[cols="1,1,1"] +[cols="1,2",options="header"] |=== -|Component |Status |Location - -|Agda AST specification -|🔨 In Progress -|`proofs/agda/SSG/Specification.agda` - -|Idempotence proofs -|📋 Planned -|`proofs/agda/SSG/Idempotence.agda` - -|Well-formedness proofs -|📋 Planned -|`proofs/agda/SSG/WellFormed.agda` - -|Lean template formalisation -|📋 Planned -|`proofs/lean/SSG/` - -|Extraction pipeline -|📋 Planned -|`extraction/` - -|Rust FFI wrapper -|📋 Planned -|`src/libechidna_ssg/` +|Property |Required clarification before proving it +|Rendering idempotence +|Define whether the output is also a valid input and which normal form is compared. +|Structure preservation +|Specify the source AST, output structure and the nodes that must be preserved. +|HTML well-formedness +|Choose the accepted HTML model and validation boundary. +|Template safety +|Specify escaping, trusted markup, URL handling and execution contexts. +|Determinism +|Bind configuration, dependencies, environment inputs and output ordering. |=== -*Formal Properties Under Development*: +A future Agda or Lean development must distinguish checked theorems, +explicit assumptions, incomplete proofs, and bounded test witnesses. +Extraction also needs an ABI and tests of the generated artifact; a source +proof does not automatically establish the behavior of an FFI consumer. -1. **Rendering Idempotence**: `render(render(x)) = render(x)` -2. **Structure Preservation**: Heading counts preserved across transformation -3. **HTML Well-formedness**: Output always valid HTML5 -4. **Template Safety**: No script injection through variables -5. **Determinism**: Same input always produces same output +== Delivery gates -== Timeline +The previous 2025 dates were proposals and are not current completion +receipts. No replacement dates are asserted here. -[cols="1,2,1"] -|=== -|Phase |Deliverables |Target +. Specify one small end-to-end use case and its observable acceptance criteria. +. Implement the renderer or property encoder using the existing backend interface. +. Add positive and adversarial fixtures, including unavailable tools and unsupported inputs. +. Run the selected real backend and retain its command, version, input and result. +. Add proof obligations with explicit assumptions and rejection controls. +. Qualify extraction and bindings before describing a shared library as available. -|*Q1 2025* -|`echidna-docs` MVP with Agda/Lean/Coq support -|✅ On Track +Each gate needs evidence from the revision being assessed. A missing tool, +skipped test, unimplemented path or absent artifact remains unknown. -|*Q2 2025* -|`echidna-verify` MCP server beta -|🟡 Scheduled +== Integration context and references -|*Q3 2025* -|Formal specifications complete, proof sketches -|🔵 Scheduled +The proposed consumer is the polyglot SSG ecosystem. The role envisaged +for ECHIDNA is property checking and proof-content processing; that is an +integration goal, not a current service contract. -|*Q4 2025* -|`libechidna-ssg` extraction pipeline prototype -|🔵 Scheduled - -|*2026* -|Production `libechidna-ssg` with polyglot bindings -|📋 Roadmap -|=== +Earlier versions of this roadmap remain available in version control at +the source revision named above; they are historical proposals. -== Integration with polyglot-ssg-mcp - -ECHIDNA serves as the verification layer for the polyglot-ssg-mcp project: - -[source] ----- -┌─────────────────────────────────────────────────────┐ -│ polyglot-ssg-mcp │ -│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ -│ │ Forth │ │ Ada │ │ Haskell │ ... │ -│ │ SSG │ │ SSG │ │ SSG │ │ -│ └────┬────┘ └────┬────┘ └────┬────┘ │ -│ │ │ │ │ -│ └────────────┼────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────┐ │ -│ │ echidna-verify MCP │ ◄── Verification │ -│ │ Property Oracle │ Layer │ -│ └────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ ----- - -== Contributing - -We particularly welcome contributions in: - -* **Formal verification**: Agda, Lean, Coq, Isabelle proofs -* **SSG implementation**: Rust, proof rendering -* **MCP tooling**: Protocol handling, solver integration -* **Test corpus**: Diverse markdown/proof content for testing - -See link:../CONTRIBUTING.md[CONTRIBUTING.md] for guidelines. - -== References - -* link:docs/SSG_INTEGRATION.adoc[Full SSG Integration Architecture] -* link:src/echidna_docs/lib.rs[echidna-docs source] -* link:src/echidna_verify/lib.rs[echidna-verify source] -* link:proofs/agda/SSG/Specification.agda[Agda specification] +* link:../.github/CONTRIBUTING.md[Contribution guidelines]. +* link:reports/2026-11-04-security-alert-triage.adoc[Source review and alert disposition]. diff --git a/scripts/ci/run-live-prover.sh b/scripts/ci/run-live-prover.sh index 2dbff5e2..623db343 100644 --- a/scripts/ci/run-live-prover.sh +++ b/scripts/ci/run-live-prover.sh @@ -11,6 +11,8 @@ case "$backend" in spass) executable=SPASS ;; glpk) executable=glpsol ;; chuffed) executable=fzn-chuffed ;; + gpuverify) executable=gpuverify ;; + faial) executable=faial-drf ;; *) echo "::error::Unsupported required backend: $backend"; exit 2 ;; esac for tool in cargo "$executable"; do diff --git a/scripts/ci/static-analysis-evidence.sh b/scripts/ci/static-analysis-evidence.sh new file mode 100755 index 00000000..f27e599e --- /dev/null +++ b/scripts/ci/static-analysis-evidence.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Execution receipts for Static Analysis Gate. These are scan observations, +# never verified repair outcomes or evidence that fleet learning occurred. +set -euo pipefail + +root=${SCAN_ROOT:-$PWD} +subject=$(git -C "$root" rev-parse HEAD) +subject_tree=$(git -C "$root" rev-parse 'HEAD^{tree}') +worktree_clean=false +if [ -z "$(git -C "$root" status --porcelain)" ]; then worktree_clean=true; fi +expected=${SCAN_EXPECTED_SHA:?the workflow must name the exact scanned commit} +repository=${GITHUB_REPOSITORY:-local/fixture} +run_id=${GITHUB_RUN_ID:-local} +run_attempt=${GITHUB_RUN_ATTEMPT:-1} + +one_json() { + jq -e -s 'length == 1' "$1" >/dev/null 2>&1 +} + +valid_report() { + local tool=$1 report=$2 + case "$tool" in + hypatia) + jq -e -s 'length == 1 and (.[0] | type == "array" and all(.[]; + type == "object" and (.severity as $s | + ["critical","high","medium","warn","low","info","informational"] | index($s) != null)))' "$report" >/dev/null 2>&1 ;; + panic-attack) + jq -e -s 'length == 1 and (.[0] | type == "object" and .schema_version == "2.6" + and .coverage.status == "complete" and (.coverage.candidate_files | type == "number" and . > 0 and floor == .) + and .coverage.analyzed_files == .coverage.candidate_files + and (.weak_points | type == "array") and all(.weak_points[]; + type == "object" and (.severity as $s | ["Critical","High","Medium","Low"] | index($s) != null)))' "$report" >/dev/null 2>&1 ;; + patch-bridge) + jq -e -s 'length == 1 and (.[0] | type == "object" and .schema_version == "0.2.0" + and (.total_dependencies | type == "number" and . > 0 and floor == .) + and (.cves | type == "array") + and all(.cves[]; type == "object" and (.classification as $c | + ["mitigable","unmitigable","informational"] | index($c) != null)) + and all(.mitigated,.unmitigable,.concatenative,.informational,.vulnerable_dependencies; + type == "number" and . >= 0 and floor == .) + and .mitigated == ([.cves[] | select(.classification == "mitigable")] | length) + and .unmitigable == ([.cves[] | select(.classification == "unmitigable")] | length) + and .informational == ([.cves[] | select(.classification == "informational")] | length) + and .vulnerable_dependencies == (.cves | length))' "$report" >/dev/null 2>&1 ;; + *) return 1 ;; + esac +} + +counts() { + local tool=$1 report=$2 + if [ "$tool" = patch-bridge ]; then + jq -c '{mitigated,unmitigable,concatenative,informational,total:(.cves|length)}' "$report" + else + jq -c --arg tool "$tool" ' + (if $tool == "panic-attack" then [.weak_points[] | select(.suppressed != true) | .severity | ascii_downcase] + else [.[].severity | if . == "warn" then "medium" else . end] end) as $s + | {total:($s|length),critical:([$s[]|select(. == "critical")]|length), + high:([$s[]|select(. == "high")]|length),medium:([$s[]|select(. == "medium")]|length), + low:([$s[]|select(. == "low")]|length),info:([$s[]|select(. == "info" or . == "informational")]|length)}' "$report" + fi +} + +scan() { + local tool=$1 ref=$2 binary=$3 source=$4 out=$5 + mkdir -p "$out" + local report="$out/report.json" receipt="$out/receipt.json" log="$out/scanner.log" + local status=not_run reason="scanner build or source receipt is unavailable" rc=null digest="" source_sha="" + local command='[]' count_json=null + local null_report="$out/unavailable-report.json" coverage_file="$out/coverage.json" report_path + printf 'null\n' > "$null_report" + printf 'null\n' > "$coverage_file" + report_path=$null_report + rm -f -- "$report" "$receipt" + + if [ "$subject" != "$expected" ] || [ "$worktree_clean" != true ]; then + reason="checkout is dirty or does not match the requested scan commit" + elif [ -x "$binary" ] && [ -s "$source" ] && one_json "$source" && + jq -e --arg ref "$ref" '.source_sha == $ref and (.binary_sha256 | test("^[0-9a-f]{64}$"))' "$source" >/dev/null; then + digest=$(sha256sum "$binary" | cut -d' ' -f1) + source_sha=$(jq -r '.source_sha' "$source") + if [ "$digest" != "$(jq -r '.binary_sha256' "$source")" ]; then + reason="scanner binary does not match its build receipt" + else + local -a argv + case "$tool" in + panic-attack) argv=("$binary" assail "$root" --output "$report" --fail-on-incomplete) ;; + hypatia) argv=("$binary" scan "$root" --format json --exit-zero) ;; + patch-bridge) argv=("$binary" bridge triage "$root" --output "$report") ;; + *) echo "Unknown scanner: $tool" >&2; return 2 ;; + esac + command=$(jq -cn --args '$ARGS.positional' -- "${argv[@]}") + rc=0 + if [ "$tool" = hypatia ]; then + timeout 300 "${argv[@]}" > "$report" 2> "$log" || rc=$? + else + timeout 300 "${argv[@]}" > "$log" 2>&1 || rc=$? + fi + # Preserve the tool's own partial coverage even when its nonzero exit + # prevents qualification. A partial report is evidence, not a clean scan. + if [ "$tool" = panic-attack ] && one_json "$report" && + jq -e 'type == "object" and (.coverage | type == "object")' "$report" >/dev/null 2>&1; then + jq -c '.coverage' "$report" > "$coverage_file" + fi + status=error + if [ "$rc" -ne 0 ]; then + reason="scanner execution failed or timed out (exit $rc); raw output retained" + elif ! valid_report "$tool" "$report"; then + reason="scanner returned no complete report matching its pinned output contract" + else + status=ran + reason="" + report_path=$report + count_json=$(counts "$tool" "$report") + case "$tool" in + panic-attack) jq -c '.coverage' "$report" > "$coverage_file" ;; + patch-bridge) jq -c '{mode:"online",total_dependencies}' "$report" > "$coverage_file" ;; + hypatia) printf '%s\n' '{"mode":"hypatia-default-rules","severity_threshold":"medium","remote_alert_credentials":"not_supplied"}' > "$coverage_file" ;; + esac + fi + fi + fi + jq -cn --arg status "$status" --arg tool "$tool" --arg ref "$ref" --arg reason "$reason" \ + --argjson exit_code "$rc" --argjson counts "$count_json" --slurpfile report "$report_path" \ + --arg repo "$repository" --arg sha "$subject" --arg expected "$expected" --arg run "$run_id" \ + --arg attempt "$run_attempt" --arg source "$source_sha" --arg digest "$digest" \ + --arg tree "$subject_tree" --argjson clean "$worktree_clean" \ + --argjson command "$command" --slurpfile coverage "$coverage_file" ' + {status:$status,tool:$tool,ref:$ref,reason:(if $reason == "" then null else $reason end), + exit_code:$exit_code,counts:$counts,report:$report[0],coverage:$coverage[0], + provenance:{repository:$repo,commit_sha:$sha,tree_sha:$tree,worktree_clean:$clean, + expected_sha:$expected,run_id:$run,run_attempt:$attempt, + source_sha:(if $source == "" then null else $source end), + binary_sha256:(if $digest == "" then null else $digest end),command:$command}, + evidence_kind:"scanner_observation"}' > "$receipt" + rm -f -- "$null_report" + if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf 'status=%s\n' "$status" >> "$GITHUB_OUTPUT" + fi + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + printf '## %s\n\nStatus: **%s**\n\nSource: `%s`\n\nScanned commit: `%s`\n\n' "$tool" "$status" "$ref" "$subject" + if [ "$status" = ran ]; then jq -r '.counts | to_entries[] | "- \(.key): \(.value)"' "$receipt" + else printf '%s. This is not a clean scan.\n' "$reason"; fi + } >> "$GITHUB_STEP_SUMMARY" + fi + if [ "$status" != ran ]; then + printf '::error::%s: %s\n' "$tool" "$reason" >&2 + [ ! -f "$log" ] || cat "$log" >&2 + return 2 + fi +} + +expected_ref() { + case "$1" in + hypatia) printf '%s' "${HYPATIA_REF:?}" ;; + panic-attack|patch-bridge) printf '%s' "${PANIC_ATTACK_REF:?}" ;; + *) return 1 ;; + esac +} + +qualified_receipt() { + local tool=$1 ref=$2 receipt=$3 report=$4 expected_counts + [ "$subject" = "$expected" ] || return 1 + [ -s "$receipt" ] && one_json "$receipt" || return 1 + jq -e --arg tool "$tool" --arg ref "$ref" --arg sha "$expected" --arg tree "$subject_tree" \ + --arg repo "$repository" --arg run "$run_id" --arg attempt "$run_attempt" ' + .tool == $tool and .ref == $ref and .status == "ran" and .exit_code == 0 + and .provenance.repository == $repo and .provenance.commit_sha == $sha + and .provenance.tree_sha == $tree and .provenance.worktree_clean == true + and .provenance.expected_sha == $sha and .provenance.source_sha == $ref + and .provenance.run_id == $run and .provenance.run_attempt == $attempt + and (.provenance.binary_sha256 | test("^[0-9a-f]{64}$"))' "$receipt" >/dev/null 2>&1 || return 1 + jq '.report' "$receipt" > "$report" || return 1 + valid_report "$tool" "$report" || return 1 + expected_counts=$(counts "$tool" "$report") || return 1 + jq -e --argjson counts "$expected_counts" '.counts == $counts' "$receipt" >/dev/null +} + +gate() { + local receipt=$1 tool ref report + one_json "$receipt" || return 2 + tool=$(jq -r '.tool' "$receipt") + ref=$(expected_ref "$tool") || return 2 + report=$(mktemp) + if ! qualified_receipt "$tool" "$ref" "$receipt" "$report"; then + rm -f -- "$report" + echo '::error::Scanner completion receipt is missing, incomplete or inconsistent.' >&2 + return 2 + fi + rm -f -- "$report" + if [ "$tool" = patch-bridge ]; then + if [ "$(jq -r .counts.unmitigable "$receipt")" -gt 0 ]; then + echo '::warning::Patch Bridge found unmitigable CVEs; architectural review is required.' + fi + elif [ "$(jq -r .counts.critical "$receipt")" -gt 0 ]; then + echo '::error::Critical scanner findings block merge; the original findings remain in the artifact.' + return 1 + fi +} + +deposit() { + local dir=$1 complete=true tool ref receipt name temp + mkdir -p "$dir" + rm -f -- "$dir/unified-findings.json" + temp=$(mktemp -d) + trap 'rm -rf -- "$temp"' RETURN + for tool in panic-attack hypatia patch-bridge; do + case "$tool" in + panic-attack) name=panic-attack-findings; ref=${PANIC_ATTACK_REF:?} ;; + hypatia) name=hypatia-findings; ref=${HYPATIA_REF:?} ;; + patch-bridge) name=bridge-report; ref=${PANIC_ATTACK_REF:?} ;; + esac + receipt="$dir/$name/receipt.json" + if qualified_receipt "$tool" "$ref" "$receipt" "$temp/$tool-report.json"; then + jq '{tool,status,ref,provenance,coverage}' "$receipt" > "$temp/$tool-status.json" + continue + fi + complete=false + local receipt_status=not_run + if [ -e "$receipt" ]; then receipt_status=error; fi + jq -cn --arg tool "$tool" --arg status "$receipt_status" \ + '{tool:$tool,status:$status,reason:"missing, failed, malformed or mismatched scan receipt"}' > "$temp/$tool-status.json" + done + jq -cs --argjson complete "$complete" \ + '{complete:$complete,evidence_kind:"scanner_observation",scanners:.,fleet_ingestion:"not_confirmed",repair_verification:"not_performed"}' \ + "$temp/panic-attack-status.json" "$temp/hypatia-status.json" "$temp/patch-bridge-status.json" > "$dir/deposit-status.json" + if [ "$complete" != true ]; then + echo '::error::Incomplete scanner evidence; no unified-findings artifact or clean result is produced.' >&2 + return 2 + fi + jq -cn --arg repo "$repository" --arg sha "$subject" --arg ref "${GITHUB_REF:-local}" \ + --arg run "$run_id" --arg attempt "$run_attempt" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --slurpfile pa "$temp/panic-attack-report.json" --slurpfile hyp "$temp/hypatia-report.json" \ + --slurpfile bridge "$temp/patch-bridge-report.json" --slurpfile status "$dir/deposit-status.json" ' + {schema_version:"1.1.0",repository:$repo,commit_sha:$sha,ref:$ref,run_id:$run,run_attempt:$attempt,timestamp:$ts, + evidence_kind:"scanner_observation",scan_completion:$status[0], + findings:([$pa[0].weak_points[] | select(.suppressed != true) | + . + {scanner:"panic-attack",source_severity:.severity,severity:(.severity|ascii_downcase),reason:.description}] + + [$hyp[0][] | . + {scanner:"hypatia"}]),patch_bridge:$bridge[0]}' > "$dir/unified-findings.json" +} + +case "${1:-}" in + scan) shift; scan "$@" ;; + gate) shift; gate "$@" ;; + deposit) shift; deposit "$@" ;; + *) echo 'usage: static-analysis-evidence.sh scan TOOL REF BIN SOURCE-RECEIPT OUTPUT-DIR | gate RECEIPT | deposit ARTIFACT-DIR' >&2; exit 2 ;; +esac diff --git a/scripts/ci/test-static-analysis-evidence.sh b/scripts/ci/test-static-analysis-evidence.sh new file mode 100755 index 00000000..d9960f7d --- /dev/null +++ b/scripts/ci/test-static-analysis-evidence.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Offline planted controls for the execution/receipt boundary, not scanner detections. +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +adapter="$repo/scripts/ci/static-analysis-evidence.sh" +tmp=$(mktemp -d) +trap 'rm -rf -- "$tmp"' EXIT +mkdir "$tmp/input" +git -C "$tmp/input" init -q +printf 'fn main() {}\n' > "$tmp/input/main.rs" +git -C "$tmp/input" add main.rs +git -C "$tmp/input" -c user.name='Scanner fixture' -c user.email='fixture@example.invalid' \ + -c commit.gpgsign=false commit -qm 'Synthetic scanner input' +export SCAN_ROOT="$tmp/input" SCAN_EXPECTED_SHA +SCAN_EXPECTED_SHA=$(git -C "$SCAN_ROOT" rev-parse HEAD) +export GITHUB_REPOSITORY=fixture/static-analysis GITHUB_RUN_ID=123 GITHUB_RUN_ATTEMPT=2 +export HYPATIA_REF=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +export PANIC_ATTACK_REF=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY +export MOCK_REPORT="$tmp/report.json" MOCK_ARGS="$tmp/args" MOCK_EXIT=0 + +cat > "$tmp/scanner" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$MOCK_ARGS" +if [ "$1" = scan ]; then + cat "$MOCK_REPORT" +else + while [ "$#" -gt 0 ]; do + if [ "$1" = --output ]; then cp "$MOCK_REPORT" "$2"; break; fi + shift + done +fi +exit "$MOCK_EXIT" +MOCK +chmod +x "$tmp/scanner" +digest=$(sha256sum "$tmp/scanner" | cut -d' ' -f1) +jq -cn --arg sha "$HYPATIA_REF" --arg digest "$digest" '{source_sha:$sha,binary_sha256:$digest}' > "$tmp/hypatia-source.json" +jq -cn --arg sha "$PANIC_ATTACK_REF" --arg digest "$digest" '{source_sha:$sha,binary_sha256:$digest}' > "$tmp/panic-source.json" +passed=0 + +expect_exit() { + local wanted=$1 actual=0 + shift + "$@" > "$tmp/control.log" 2>&1 || actual=$? + if [ "$actual" -ne "$wanted" ]; then + cat "$tmp/control.log" >&2 + printf 'Expected exit %s, received %s: %s\n' "$wanted" "$actual" "$*" >&2 + exit 1 + fi + passed=$((passed + 1)) +} + +hypatia() { + bash "$adapter" scan hypatia "$HYPATIA_REF" "$tmp/scanner" "$tmp/hypatia-source.json" "$tmp/hypatia" +} +panic() { + bash "$adapter" scan panic-attack "$PANIC_ATTACK_REF" "$tmp/scanner" "$tmp/panic-source.json" "$tmp/panic" +} +bridge() { + bash "$adapter" scan patch-bridge "$PANIC_ATTACK_REF" "$tmp/scanner" "$tmp/panic-source.json" "$tmp/bridge" +} + +# Complete clean output is legitimate; empty bytes, malformed and multiple +# JSON documents are not a substitute for that successful scan. +printf '[]\n' > "$MOCK_REPORT" +expect_exit 0 hypatia +jq -e '.status == "ran" and .counts.total == 0' "$tmp/hypatia/receipt.json" >/dev/null +expect_exit 0 bash "$adapter" gate "$tmp/hypatia/receipt.json" +: > "$MOCK_REPORT" +expect_exit 2 hypatia +jq -e '.status == "error" and .counts == null and .report == null' "$tmp/hypatia/receipt.json" >/dev/null +printf 'not-json\n' > "$MOCK_REPORT" +expect_exit 2 hypatia +printf '[]\n[]\n' > "$MOCK_REPORT" +expect_exit 2 hypatia +printf '[]\n' > "$MOCK_REPORT" +export MOCK_EXIT=2 +expect_exit 2 hypatia +jq -e '.exit_code == 2 and .counts == null' "$tmp/hypatia/receipt.json" >/dev/null +export MOCK_EXIT=0 +printf '[{"severity":"critical","reason":"planted critical control"}]\n' > "$MOCK_REPORT" +expect_exit 0 hypatia +expect_exit 1 bash "$adapter" gate "$tmp/hypatia/receipt.json" +jq -e '.report[0].reason == "planted critical control" and .counts.critical == 1' "$tmp/hypatia/receipt.json" >/dev/null +cp "$tmp/hypatia/receipt.json" "$tmp/qualified-hypatia.json" +jq '.counts.critical = 0' "$tmp/qualified-hypatia.json" > "$tmp/inconsistent-receipt.json" +expect_exit 2 bash "$adapter" gate "$tmp/inconsistent-receipt.json" +printf '{"status":"ran","tool":"hypatia","counts":{"critical":null}}\n' > "$tmp/inconsistent-receipt.json" +expect_exit 2 bash "$adapter" gate "$tmp/inconsistent-receipt.json" + +expect_exit 2 bash "$adapter" scan hypatia "$HYPATIA_REF" "$tmp/missing" "$tmp/hypatia-source.json" "$tmp/missing-tool" +jq -e '.status == "not_run" and .exit_code == null and .counts == null' "$tmp/missing-tool/receipt.json" >/dev/null +expect_exit 2 bash "$adapter" scan hypatia "$HYPATIA_REF" "$tmp/scanner" "$tmp/missing-source" "$tmp/missing-source-result" +printf '// changed input\n' >> "$SCAN_ROOT/main.rs" +expect_exit 2 hypatia +jq -e '.status == "not_run" and .provenance.worktree_clean == false' "$tmp/hypatia/receipt.json" >/dev/null +git -C "$SCAN_ROOT" checkout -- main.rs + +# panic-attack reports an object with explicit coverage. The pinned scanner's +# own suppressed markers remain in raw evidence, and never inflate gate counts. +printf '%s\n' '{"schema_version":"2.6","coverage":{"status":"complete","candidate_files":1,"analyzed_files":1},"weak_points":[{"severity":"Critical","description":"suppressed fixture","suppressed":true},{"severity":"High","description":"live fixture"}]}' > "$MOCK_REPORT" +expect_exit 0 panic +grep -Fxq -- '--fail-on-incomplete' "$MOCK_ARGS" +jq -e '.counts.critical == 0 and .counts.high == 1 and (.report.weak_points|length) == 2' "$tmp/panic/receipt.json" >/dev/null +cp "$tmp/panic/receipt.json" "$tmp/qualified-panic.json" +# Real repositories have large coverage inventories: do not pass reports as +# shell arguments (Linux limits a single argument to roughly 128 KiB). +jq '.coverage.candidate_files = 2000 | .coverage.analyzed_files = 2000 | + .coverage.inputs = [range(0;2000) | {path:("fixture/source/" + tostring + "/long-module-name-for-coverage-regression.rs"),status:"analyzed"}]' \ + "$MOCK_REPORT" > "$tmp/large-report.json" +cp "$tmp/large-report.json" "$MOCK_REPORT" +expect_exit 0 panic +jq -e '(.coverage.inputs|length) == 2000 and .status == "ran"' "$tmp/panic/receipt.json" >/dev/null +jq '.coverage.status = "partial"' "$MOCK_REPORT" > "$tmp/partial.json" +cp "$tmp/partial.json" "$MOCK_REPORT" +expect_exit 2 panic +jq -e '.counts == null and .report == null' "$tmp/panic/receipt.json" >/dev/null +jq -e '.coverage.status == "partial"' "$tmp/panic/report.json" >/dev/null +printf '[]\n' > "$MOCK_REPORT" +expect_exit 2 panic + +# Bridge needs its real subcommand and output-file contract. CLI/parser errors +# cannot turn into a fabricated zero-CVE report; no offline zero is requested. +printf '%s\n' '{"schema_version":"0.2.0","total_dependencies":2,"vulnerable_dependencies":1,"cves":[{"classification":"unmitigable"}],"mitigated":0,"unmitigable":1,"concatenative":0,"informational":0}' > "$MOCK_REPORT" +expect_exit 0 bridge +grep -Fxq bridge "$MOCK_ARGS" +grep -Fxq triage "$MOCK_ARGS" +if grep -Fxq -- '--offline' "$MOCK_ARGS"; then exit 1; fi +expect_exit 0 bash "$adapter" gate "$tmp/bridge/receipt.json" +cp "$tmp/bridge/receipt.json" "$tmp/qualified-bridge.json" +export MOCK_EXIT=2 +expect_exit 2 bridge +export MOCK_EXIT=0 +jq '.unmitigable = 0' "$MOCK_REPORT" > "$tmp/inconsistent.json" +cp "$tmp/inconsistent.json" "$MOCK_REPORT" +expect_exit 2 bridge + +# Deposit preserves actual critical findings even though their gate failed. +# Only mutually matching completed scan receipts may create the legacy artifact. +mkdir -p "$tmp/findings/"{panic-attack-findings,hypatia-findings,bridge-report} +cp "$tmp/qualified-panic.json" "$tmp/findings/panic-attack-findings/receipt.json" +cp "$tmp/qualified-hypatia.json" "$tmp/findings/hypatia-findings/receipt.json" +cp "$tmp/qualified-bridge.json" "$tmp/findings/bridge-report/receipt.json" +expect_exit 0 bash "$adapter" deposit "$tmp/findings" +jq -e '.scan_completion.complete and .scan_completion.fleet_ingestion == "not_confirmed" + and (.findings|length) == 2 and any(.findings[]; .severity == "critical")' "$tmp/findings/unified-findings.json" >/dev/null +mv "$tmp/findings/bridge-report/receipt.json" "$tmp/bridge-backup.json" +expect_exit 2 bash "$adapter" deposit "$tmp/findings" +test ! -e "$tmp/findings/unified-findings.json" +jq -e '.complete == false' "$tmp/findings/deposit-status.json" >/dev/null +cp "$tmp/bridge-backup.json" "$tmp/findings/bridge-report/receipt.json" +jq '.provenance.commit_sha = "cccccccccccccccccccccccccccccccccccccccc"' "$tmp/qualified-hypatia.json" > "$tmp/findings/hypatia-findings/receipt.json" +expect_exit 2 bash "$adapter" deposit "$tmp/findings" +test ! -e "$tmp/findings/unified-findings.json" +cp "$tmp/qualified-hypatia.json" "$tmp/findings/hypatia-findings/receipt.json" +cp "$tmp/missing-tool/receipt.json" "$tmp/findings/panic-attack-findings/receipt.json" +expect_exit 2 bash "$adapter" deposit "$tmp/findings" +test ! -e "$tmp/findings/unified-findings.json" +printf 'not-json\n' > "$tmp/findings/panic-attack-findings/receipt.json" +expect_exit 2 bash "$adapter" deposit "$tmp/findings" +test ! -e "$tmp/findings/unified-findings.json" +printf '%s receipt controls passed\n' "$passed" diff --git a/src/interfaces/graphql/main.rs b/src/interfaces/graphql/main.rs index be6217b3..48225510 100644 --- a/src/interfaces/graphql/main.rs +++ b/src/interfaces/graphql/main.rs @@ -17,12 +17,13 @@ use schema::{MutationRoot, QueryRoot}; /// Runs the GraphQL endpoint and playground at `/` and the health check at /// `/health`, with permissive CORS. The bind address comes from /// `ECHIDNA_GRAPHQL_ADDR`, defaulting to `127.0.0.1:8082` when it is absent. -/// Invalid configuration, bind failures and server errors are returned with -/// context. FFI initialisation failure falls back to direct Rust calls. +/// Invalid configuration, HTTP client setup, bind failures and server errors +/// are returned with context. FFI initialisation failure falls back to direct +/// Rust calls. /// /// # Panics /// -/// Panics if logging or the HTTP client cannot be initialised. +/// Panics if logging cannot be initialised. #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -42,7 +43,7 @@ async fn main() -> anyhow::Result<()> { .with_context(|| format!("Could not bind ECHIDNA_GRAPHQL_ADDR={addr:?}"))?; // Create ECHIDNA context with core integration - let echidna_ctx = EchidnaContext::new(); + let echidna_ctx = EchidnaContext::new()?; // Build GraphQL schema with ECHIDNA context let schema = Schema::build(QueryRoot, MutationRoot, EmptySubscription) diff --git a/src/interfaces/graphql/resolvers.rs b/src/interfaces/graphql/resolvers.rs index d9fd3ab0..7571e065 100644 --- a/src/interfaces/graphql/resolvers.rs +++ b/src/interfaces/graphql/resolvers.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 // GraphQL resolvers - wired to ECHIDNA core +use anyhow::Context; use echidna::core::{ ProofState as CoreProofState, Tactic as CoreTactic, TacticResult as CoreTacticResult, Term, }; @@ -471,11 +472,16 @@ pub struct EchidnaContext { } impl EchidnaContext { - pub fn new() -> Self { - let ml_client = reqwest::Client::builder() + /// Initialize shared state, returning HTTP setup failures to the server. + pub fn new() -> anyhow::Result { + Self::with_builder(reqwest::Client::builder()) + } + + fn with_builder(builder: reqwest::ClientBuilder) -> anyhow::Result { + let ml_client = builder .timeout(std::time::Duration::from_secs(10)) .build() - .expect("Failed to create HTTP client"); + .context("Failed to create GraphQL ML HTTP client")?; // Initialize FFI layer let ffi_initialized = ffi_wrapper::init_ffi().is_ok(); @@ -485,12 +491,12 @@ impl EchidnaContext { tracing::warn!("FFI layer initialization failed, falling back to direct Rust calls"); } - EchidnaContext { + Ok(EchidnaContext { sessions: Arc::new(RwLock::new(HashMap::new())), ml_client, ml_api_url: "http://127.0.0.1:8090".to_string(), ffi_initialized, - } + }) } pub async fn create_session(&self, goal: &str, kind: CoreProverKind) -> anyhow::Result { @@ -742,3 +748,18 @@ impl EchidnaContext { session.prover.suggest_tactics(proof_state, limit).await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_http_client_build_error_is_returned() { + let error = + EchidnaContext::with_builder(reqwest::Client::builder().user_agent("invalid\nheader")) + .err() + .expect("invalid HTTP headers must reject context construction"); + assert!(error.downcast_ref::().unwrap().is_builder()); + assert!(error.to_string().contains("GraphQL ML HTTP client")); + } +} diff --git a/src/interfaces/graphql/schema.rs b/src/interfaces/graphql/schema.rs index 1607d417..aa65a1c7 100644 --- a/src/interfaces/graphql/schema.rs +++ b/src/interfaces/graphql/schema.rs @@ -544,7 +544,9 @@ impl MutationRoot { // Fetch back the created session state let sessions = echidna_ctx.sessions.read().await; - let session_arc = sessions.get(&proof_id).unwrap().clone(); + let session_arc = sessions.get(&proof_id).cloned().ok_or_else(|| { + async_graphql::Error::new("Created proof session is no longer available") + })?; drop(sessions); let session = session_arc.lock().await; @@ -1103,7 +1105,7 @@ mod tests { fn build_schema() -> Schema { Schema::build(QueryRoot, MutationRoot, EmptySubscription) - .data(EchidnaContext::new()) + .data(EchidnaContext::new().expect("GraphQL context should initialize")) .finish() } diff --git a/src/interfaces/rest/main.rs b/src/interfaces/rest/main.rs index 0f99a4a4..38cdab75 100644 --- a/src/interfaces/rest/main.rs +++ b/src/interfaces/rest/main.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 // ECHIDNA REST API Server +use anyhow::Context; use axum::{ routing::{delete, get, post}, Router, @@ -57,13 +58,13 @@ pub struct ProofSession { /// Binds to `ECHIDNA_REST_ADDR`, defaulting to `127.0.0.1:8000` when the variable /// is absent or is not valid Unicode. Probes Julia ML at `http://127.0.0.1:8090` /// and initialises the FFI layer; failures of either check do not prevent startup. +/// HTTP client setup, listener binding and serving failures return errors. /// /// # Panics /// -/// Panics if tracing initialisation, HTTP client creation, listener binding or -/// serving fails. +/// Panics if tracing initialisation fails. #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); @@ -71,7 +72,7 @@ async fn main() { let ml_client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() - .expect("Failed to create HTTP client"); + .context("Failed to create REST ML HTTP client")?; let ml_api_url = "http://127.0.0.1:8090".to_string(); @@ -101,7 +102,7 @@ async fn main() { ml_client, ml_api_url, ffi_initialized, - llm_advisor: Arc::new(Mutex::new(echidna::llm::LlmAdvisor::new())), + llm_advisor: Arc::new(Mutex::new(echidna::llm::LlmAdvisor::new()?)), }; let app = Router::new() @@ -143,8 +144,13 @@ async fn main() { tracing::info!("REST API listening on http://{}", addr); tracing::info!("API docs (ReDoc) available at http://{}/redoc", addr); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .with_context(|| format!("Could not bind ECHIDNA_REST_ADDR={addr:?}"))?; + axum::serve(listener, app) + .await + .context("REST server failed")?; + Ok(()) } async fn health_check() -> &'static str { diff --git a/src/rust/coprocessor/flint.rs b/src/rust/coprocessor/flint.rs index 8384cc27..c80e95c6 100644 --- a/src/rust/coprocessor/flint.rs +++ b/src/rust/coprocessor/flint.rs @@ -163,15 +163,15 @@ impl Fmpz { } } - fn get_str(&self) -> String { + fn get_str(&self) -> Result { // FLINT allocates when str argument is NULL; we free with flint_free. let ptr: *mut c_char = unsafe { fmpz_get_str(std::ptr::null_mut(), 10, &self.inner) }; if ptr.is_null() { - return "0".to_string(); + return Err(anyhow!("FLINT returned no decimal string")); } let s = unsafe { CStr::from_ptr(ptr).to_string_lossy().into_owned() }; unsafe { flint_free(ptr.cast()) }; - s + Ok(s) } } @@ -240,18 +240,18 @@ fn parse_poly(s: &str) -> Result { /// Returns `"0"` for the zero polynomial; otherwise returns space-separated /// coefficients from degree 0 to degree n (the output is FLINT-normalised, /// so the leading coefficient is never zero). -fn poly_to_string(poly: &FmpzPoly) -> String { +fn poly_to_string(poly: &FmpzPoly) -> Result { let len = poly.length(); if len == 0 { - return "0".to_string(); + return Ok("0".to_string()); } let mut coeff = Fmpz::new(); let mut parts = Vec::with_capacity(len as usize); for i in 0..len { unsafe { fmpz_poly_get_coeff_fmpz(&mut coeff.inner, &poly.inner, i) }; - parts.push(coeff.get_str()); + parts.push(coeff.get_str()?); } - parts.join(" ") + Ok(parts.join(" ")) } // ── Backend ─────────────────────────────────────────────────────────────────── @@ -325,7 +325,7 @@ fn dispatch_sync(op: CoprocessorOp) -> Result { let gp = parse_poly(&g)?; let mut res = FmpzPoly::new(); unsafe { fmpz_poly_gcd(&mut res.inner, &fp.inner, &gp.inner) }; - Ok(CoprocessorOutcome::Polynomial(poly_to_string(&res))) + Ok(CoprocessorOutcome::Polynomial(poly_to_string(&res)?)) }, CoprocessorOp::FlintPolyMul { f, g } => { @@ -333,7 +333,7 @@ fn dispatch_sync(op: CoprocessorOp) -> Result { let gp = parse_poly(&g)?; let mut res = FmpzPoly::new(); unsafe { fmpz_poly_mul(&mut res.inner, &fp.inner, &gp.inner) }; - Ok(CoprocessorOutcome::Polynomial(poly_to_string(&res))) + Ok(CoprocessorOutcome::Polynomial(poly_to_string(&res)?)) }, CoprocessorOp::FlintPolyRem { f, g } => { @@ -347,14 +347,14 @@ fn dispatch_sync(op: CoprocessorOp) -> Result { let mut rem = FmpzPoly::new(); let mut d: Ulong = 0; unsafe { fmpz_poly_pseudo_rem(&mut rem.inner, &mut d, &fp.inner, &gp.inner) }; - Ok(CoprocessorOutcome::Polynomial(poly_to_string(&rem))) + Ok(CoprocessorOutcome::Polynomial(poly_to_string(&rem)?)) }, CoprocessorOp::FlintPolyContent { f } => { let fp = parse_poly(&f)?; let mut content = Fmpz::new(); unsafe { fmpz_poly_content(&mut content.inner, &fp.inner) }; - Ok(CoprocessorOutcome::BigInt(content.get_str())) + Ok(CoprocessorOutcome::BigInt(content.get_str()?)) }, CoprocessorOp::FlintNthRoot { n, k } => { @@ -362,10 +362,18 @@ fn dispatch_sync(op: CoprocessorOp) -> Result { return Err(anyhow!("FlintNthRoot: exponent k must be ≥ 1, got 0")); } let nz = Fmpz::from_str(&n)?; + // FLINT raises a native exception for an even root of a negative + // integer. Reject it before crossing the FFI boundary. + if k % 2 == 0 && nz.get_str()?.starts_with('-') { + return Err(anyhow!( + "FlintNthRoot: an even root requires a non-negative integer" + )); + } + let exponent = Slong::from(k); let mut root = Fmpz::new(); - let exact = unsafe { fmpz_root(&mut root.inner, &nz.inner, k as Slong) }; + let exact = unsafe { fmpz_root(&mut root.inner, &nz.inner, exponent) }; if exact == 1 { - Ok(CoprocessorOutcome::BigInt(root.get_str())) + Ok(CoprocessorOutcome::BigInt(root.get_str()?)) } else { Ok(CoprocessorOutcome::Empty) } @@ -374,7 +382,7 @@ fn dispatch_sync(op: CoprocessorOp) -> Result { CoprocessorOp::FlintBinomial { n, k } => { let mut res = Fmpz::new(); unsafe { fmpz_bin_uiui(&mut res.inner, n, k) }; - Ok(CoprocessorOutcome::BigInt(res.get_str())) + Ok(CoprocessorOutcome::BigInt(res.get_str()?)) }, other => Ok(CoprocessorOutcome::Failure(format!( @@ -464,6 +472,26 @@ mod tests { } } + #[test] + fn nth_root_rejects_invalid_native_preconditions() { + for (n, k) in [("-4", 2), ("16", 0)] { + assert!(dispatch_sync(CoprocessorOp::FlintNthRoot { + n: n.to_string(), + k, + }) + .is_err()); + } + match dispatch_sync(CoprocessorOp::FlintNthRoot { + n: "-27".to_string(), + k: 3, + }) + .expect("negative odd roots are valid") + { + CoprocessorOutcome::BigInt(value) => assert_eq!(value, "-3"), + other => panic!("unexpected negative root result: {other:?}"), + } + } + #[test] fn binomial_small_and_medium() { // C(10, 3) = 120 diff --git a/src/rust/coprocessor/julia_bridge.rs b/src/rust/coprocessor/julia_bridge.rs index 71a4dfa9..4da8960b 100644 --- a/src/rust/coprocessor/julia_bridge.rs +++ b/src/rust/coprocessor/julia_bridge.rs @@ -17,7 +17,7 @@ //! `src/julia/coprocessor.jl`. Bridge probe is non-throwing — callers //! check `health()` before routing. -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -62,20 +62,29 @@ impl JuliaCoprocessorBridge { /// Construct a bridge with reported `kind` and assumed capabilities. /// Call `probe()` afterwards to refresh `health` and `capabilities` /// from the live endpoint. - pub fn new(kind: CoprocessorKind, capabilities: CoprocessorCapabilities) -> Self { + /// Returns an error if the HTTP client cannot be initialized. + pub fn new(kind: CoprocessorKind, capabilities: CoprocessorCapabilities) -> Result { + Self::with_builder(kind, capabilities, reqwest::Client::builder()) + } + + fn with_builder( + kind: CoprocessorKind, + capabilities: CoprocessorCapabilities, + builder: reqwest::ClientBuilder, + ) -> Result { let url = std::env::var("ECHIDNA_JULIA_COPROC_URL").unwrap_or_else(|_| DEFAULT_URL.to_string()); - let client = reqwest::Client::builder() + let client = builder .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) .build() - .expect("reqwest client builds with static config"); - JuliaCoprocessorBridge { + .context("Failed to create HTTP client for Julia coprocessor bridge")?; + Ok(JuliaCoprocessorBridge { kind, capabilities, health: CoprocessorHealth::Unhealthy, // until probed url, client, - } + }) } /// Non-throwing reachability probe — updates `self.health`. @@ -199,3 +208,29 @@ fn kind_name(k: CoprocessorKind) -> &'static str { CoprocessorKind::Macaulay2 => "Macaulay2", } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_http_client_build_error_is_returned() { + let error = JuliaCoprocessorBridge::with_builder( + CoprocessorKind::Math, + CoprocessorCapabilities::default(), + reqwest::Client::builder().user_agent("invalid\nheader"), + ) + .err() + .expect("invalid HTTP headers must reject bridge construction"); + assert!(error.downcast_ref::().unwrap().is_builder()); + assert!(error.to_string().contains("Julia coprocessor bridge")); + } + + #[test] + fn test_http_client_construction_does_not_claim_health() { + let bridge = + JuliaCoprocessorBridge::new(CoprocessorKind::Math, CoprocessorCapabilities::default()) + .expect("Julia bridge HTTP client should initialize"); + assert_eq!(bridge.health(), CoprocessorHealth::Unhealthy); + } +} diff --git a/src/rust/coprocessor/parigp.rs b/src/rust/coprocessor/parigp.rs index 86ed07e6..64a15174 100644 --- a/src/rust/coprocessor/parigp.rs +++ b/src/rust/coprocessor/parigp.rs @@ -70,12 +70,8 @@ fn which(name: &str) -> bool { /// Accept only decimal integers: optional leading `-`, then ASCII digits. fn is_decimal_int(s: &str) -> bool { - if s.is_empty() { - return false; - } - let mut chars = s.chars(); - let first = chars.next().unwrap(); - (first == '-' || first.is_ascii_digit()) && chars.all(|c| c.is_ascii_digit()) + let digits = s.strip_prefix('-').unwrap_or(s); + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) } impl Default for PariGpBackend { @@ -226,6 +222,9 @@ mod tests { assert!(is_decimal_int("-7")); assert!(is_decimal_int("123456789012345678901234567890")); assert!(!is_decimal_int("")); + assert!(!is_decimal_int("-")); + assert!(!is_decimal_int("--7")); + assert!(!is_decimal_int("+7")); assert!(!is_decimal_int("4.2")); assert!(!is_decimal_int("1e10")); assert!(!is_decimal_int("1; echo pwned")); diff --git a/src/rust/dispatch.rs b/src/rust/dispatch.rs index 170aafe0..e04894cd 100644 --- a/src/rust/dispatch.rs +++ b/src/rust/dispatch.rs @@ -6,7 +6,7 @@ //! pipeline: integrity check -> sandbox -> prove -> certificate check -> //! axiom scan -> confidence scoring. -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use chrono::Utc; use serde::{Deserialize, Serialize}; #[cfg(feature = "verisim")] @@ -389,11 +389,16 @@ impl ProverDispatcher { .await } - /// Get current health status snapshot - pub fn health_status(&self) -> HealthStatus { - let mut health = self.health_status.lock().unwrap().clone(); + /// Get the current health status snapshot, or an error if its state is uncertain. + /// A poisoned lock must never be replaced with a healthy default snapshot. + pub fn health_status(&self) -> Result { + let mut health = self + .health_status + .lock() + .map_err(|_| anyhow!("Dispatcher health state is poisoned"))? + .clone(); health.compute_degradation_mode(); - health + Ok(health) } /// Update prover health metrics after a proof attempt @@ -402,8 +407,11 @@ impl ProverDispatcher { prover_kind: ProverKind, outcome: &ProverOutcome, latency_ms: u64, - ) { - let mut health = self.health_status.lock().unwrap(); + ) -> Result<()> { + let mut health = self + .health_status + .lock() + .map_err(|_| anyhow!("Dispatcher health state is poisoned"))?; let prover_name = format!("{:?}", prover_kind); let entry = health @@ -449,11 +457,12 @@ impl ProverDispatcher { entry.success_rate = entry.success_rate.clamp(0.0, 1.0); health.compute_degradation_mode(); + Ok(()) } /// Get current system degradation mode - pub fn degradation_mode(&self) -> DegradationMode { - self.health_status().system_degradation + pub fn degradation_mode(&self) -> Result { + Ok(self.health_status()?.system_degradation) } /// Dispatch with LLM-guided optimisation @@ -533,6 +542,11 @@ impl ProverDispatcher { ) -> Result { let start = Instant::now(); + // Reject a known poisoned health state before starting a backend. + // Recording below checks again, so a concurrent poison event cannot + // turn a completed backend result into a successful dispatch receipt. + self.health_status()?; + // Step 1: Create the prover let prover_config = ProverConfig { timeout: self.config.timeout, @@ -554,7 +568,7 @@ impl ProverDispatcher { Err(e) => { let outcome = classify_anyhow_error(&e, self.config.timeout); let elapsed_ms = prover_started.elapsed().as_millis() as u64; - self.record_prover_result(prover_kind, &outcome, elapsed_ms); + self.record_prover_result(prover_kind, &outcome, elapsed_ms)?; let diagnostics = if self.config.diagnostics { Some(RunDiagnostics { normalized_input: content.trim().to_string(), @@ -603,7 +617,7 @@ impl ProverDispatcher { .context("Failed to verify proof")?; let verified = outcome.is_proved(); let prover_elapsed_ms = prover_started.elapsed().as_millis() as u64; - self.record_prover_result(prover_kind, &outcome, prover_elapsed_ms); + self.record_prover_result(prover_kind, &outcome, prover_elapsed_ms)?; // Step 4: Track axiom usage let axiom_usages = if self.config.track_axioms { @@ -762,7 +776,7 @@ impl ProverDispatcher { // CosineOnly = "use cosine for premise selection instead of GNN" — independent of // multi-prover cross-checking. Only ReadOnly / Minimal (genuinely degraded, few provers // available) should skip cross-checking. - let degradation = self.degradation_mode(); + let degradation = self.degradation_mode()?; match degradation { DegradationMode::ReadOnly | DegradationMode::Minimal => { return Ok(primary_result); @@ -778,7 +792,10 @@ impl ProverDispatcher { }; let available_cross_checkers: Vec = { - let health = self.health_status.lock().unwrap(); + let health = self + .health_status + .lock() + .map_err(|_| anyhow!("Dispatcher health state is poisoned"))?; additional_provers .iter() .filter(|p| { @@ -1183,6 +1200,29 @@ fn build_proof_attempt( mod tests { use super::*; + #[tokio::test] + async fn test_poisoned_dispatch_health_rejects_snapshots_and_proofs() { + let dispatcher = ProverDispatcher::new(); + let poison = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = dispatcher + .health_status + .lock() + .expect("initially healthy lock"); + panic!("deliberate poison regression fixture"); + })); + assert!(poison.is_err()); + assert!(dispatcher.health_status().is_err()); + assert!(dispatcher.degradation_mode().is_err()); + assert!(dispatcher + .record_prover_result(ProverKind::Z3, &ProverOutcome::Proved { elapsed_ms: 1 }, 1) + .is_err()); + let error = dispatcher + .verify_proof(ProverKind::Z3, "(assert true)") + .await + .expect_err("poisoned health cannot produce a successful proof result"); + assert!(error.to_string().contains("health state is poisoned")); + } + #[test] fn test_dispatch_config_defaults() { let config = DispatchConfig::default(); diff --git a/src/rust/fault_tolerance/resilience.rs b/src/rust/fault_tolerance/resilience.rs index 2d5700e4..e3f9ac80 100644 --- a/src/rust/fault_tolerance/resilience.rs +++ b/src/rust/fault_tolerance/resilience.rs @@ -187,8 +187,23 @@ impl CircuitBreaker { } } + /// A poisoned breaker cannot authorize work or recover automatically. + fn lock_state(&self) -> Option> { + if self.last_failure.is_poisoned() { + tracing::error!(breaker = %self.name, "Circuit recovery timestamp is poisoned; refusing calls"); + return None; + } + match self.state.lock() { + Ok(state) => Some(state), + Err(_) => { + tracing::error!(breaker = %self.name, "Circuit state is poisoned; refusing calls"); + None + }, + } + } + pub fn state(&self) -> CircuitState { - *self.state.lock().unwrap() + self.lock_state().map_or(CircuitState::Open, |state| *state) } /// Identifier for this breaker (typically the prover/backend name). @@ -206,7 +221,9 @@ impl CircuitBreaker { /// Record a successful call pub fn record_success(&self) { - let mut state = self.state.lock().unwrap(); + let Some(mut state) = self.lock_state() else { + return; + }; match *state { CircuitState::HalfOpen => { self.success_count.fetch_add(1, Ordering::SeqCst); @@ -225,7 +242,9 @@ impl CircuitBreaker { /// Record a failed call pub fn record_failure(&self) { - let mut state = self.state.lock().unwrap(); + let Some(mut state) = self.lock_state() else { + return; + }; self.failure_count.fetch_add(1, Ordering::SeqCst); match *state { @@ -233,11 +252,15 @@ impl CircuitBreaker { if self.failure_count.load(Ordering::SeqCst) as usize >= self.failure_threshold => { *state = CircuitState::Open; - *self.last_failure.lock().unwrap() = Some(Instant::now()); + if let Ok(mut last_failure) = self.last_failure.lock() { + *last_failure = Some(Instant::now()); + } }, CircuitState::HalfOpen => { *state = CircuitState::Open; - *self.last_failure.lock().unwrap() = Some(Instant::now()); + if let Ok(mut last_failure) = self.last_failure.lock() { + *last_failure = Some(Instant::now()); + } }, _ => {}, } @@ -245,9 +268,14 @@ impl CircuitBreaker { /// Check if circuit can transition from Open to HalfOpen pub fn check_timeout_recovery(&self) { - let mut state = self.state.lock().unwrap(); + let Some(mut state) = self.lock_state() else { + return; + }; if let CircuitState::Open = *state { - if let Some(last_failure) = *self.last_failure.lock().unwrap() { + let Ok(last_failure) = self.last_failure.lock() else { + return; + }; + if let Some(last_failure) = *last_failure { if last_failure.elapsed() >= self.timeout { *state = CircuitState::HalfOpen; self.success_count.store(0, Ordering::SeqCst); @@ -268,6 +296,9 @@ impl CircuitBreaker { CircuitState::HalfOpen | CircuitState::Closed => match f.await { Ok(result) => { self.record_success(); + if self.state.is_poisoned() || self.last_failure.is_poisoned() { + return Err(CircuitBreakerError::CircuitOpen); + } Ok(result) }, Err(e) => { @@ -353,6 +384,54 @@ impl Drop for BulkheadGuard { mod tests { use super::*; + #[tokio::test] + async fn test_poisoned_breaker_never_runs_or_recovers() { + for poison_timestamp in [false, true] { + let breaker = CircuitBreaker::new("poison fixture".into(), 1, 1, Duration::ZERO); + breaker.record_failure(); + let outcome = std::panic::catch_unwind(|| { + if poison_timestamp { + let _guard = breaker.last_failure.lock().expect("initially healthy lock"); + panic!("deliberate timestamp poison fixture"); + } else { + let _guard = breaker.state.lock().expect("initially healthy lock"); + panic!("deliberate state poison fixture"); + } + }); + assert!(outcome.is_err()); + breaker.record_success(); + breaker.record_failure(); + breaker.check_timeout_recovery(); + assert_eq!(breaker.state(), CircuitState::Open); + let ran = std::sync::atomic::AtomicBool::new(false); + let result = breaker + .call(async { + ran.store(true, Ordering::SeqCst); + Ok::<_, String>(()) + }) + .await; + assert!(matches!(result, Err(CircuitBreakerError::CircuitOpen))); + assert!(!ran.load(Ordering::SeqCst)); + } + } + + #[tokio::test] + async fn test_breaker_poisoned_during_call_refuses_success() { + let breaker = CircuitBreaker::new("in-flight poison fixture".into(), 1, 1, Duration::ZERO); + let result = breaker + .call(async { + let outcome = std::panic::catch_unwind(|| { + let _guard = breaker.state.lock().expect("initially healthy lock"); + panic!("deliberate in-flight poison fixture"); + }); + assert!(outcome.is_err()); + Ok::<_, String>(()) + }) + .await; + assert!(matches!(result, Err(CircuitBreakerError::CircuitOpen))); + assert_eq!(breaker.state(), CircuitState::Open); + } + #[test] fn test_fibonacci() { assert_eq!(fibonacci(0), 0); diff --git a/src/rust/ffi/mod.rs b/src/rust/ffi/mod.rs index 0a06ef2e..5c276166 100644 --- a/src/rust/ffi/mod.rs +++ b/src/rust/ffi/mod.rs @@ -7,38 +7,24 @@ //! enabling external systems like bulletproof-core to use //! ECHIDNA's theorem proving capabilities. //! -//! # Safety & Unsafe Code Justification +//! # Safety obligations //! -//! This module contains 24 unsafe blocks which are **NECESSARY** for FFI: +//! Raw pointer conversion and foreign allocation ownership require unsafe +//! code. Each caller must satisfy the relevant function's pointer, length, +//! lifetime and ownership contract; a non-null pointer alone does not prove +//! those conditions. Scanner findings at this boundary require review. //! -//! ## Why Unsafe Is Required for FFI: -//! - Converting between C pointers and Rust references requires unsafe -//! - Dereferencing raw pointers from C requires unsafe -//! - Converting C strings to Rust strings requires unsafe -//! - Manual memory management for FFI requires unsafe -//! -//! ## Safety Guarantees: -//! - Every unsafe block is documented with SAFETY comments -//! - All pointer checks (null checks) before dereferencing -//! - Proper lifetime management for Box::into_raw/from_raw -//! - UTF-8 validation for string conversions -//! - Thread-safe global state with Mutex -//! -//! ## Audit Status (2026-02-12): -//! - ✓ All unsafe blocks reviewed and documented -//! - ✓ No undefined behavior detected -//! - ✓ Follows Rust FFI best practices (Rustonomicon guidelines) -//! - ✓ Null pointer checks before all dereferences -//! - ✓ Memory ownership properly tracked -//! -//! panic-attack flagged these as "High" severity because they use unsafe, -//! but they are LEGITIMATE and NECESSARY for C interoperability. +//! The 2026-11-04 review corrected the C error-message buffer's termination +//! and cross-thread lifetime. Focused tests cover those behaviors. This is +//! not a whole-module memory-safety proof or a proof that the high-level +//! compatibility exports implement the complete prover protocol. // SPARK-verified axiom-policy bindings (requires --features spark + libechidna_spark) #[cfg(feature = "spark")] pub mod spark_axiom; -use std::ffi::{c_int, c_void}; +use std::cell::RefCell; +use std::ffi::{c_int, c_void, CString}; use std::path::PathBuf; use std::ptr; @@ -109,13 +95,18 @@ impl FfiStringSlice { } } + /// Empty input is accepted; null pointers with a nonzero length are rejected. + /// /// # Safety - /// Caller must ensure `self.ptr` points to a valid, aligned, UTF-8 + /// For non-null pointers, the caller must ensure `self.ptr` points to a valid, aligned, UTF-8 /// byte buffer of at least `self.len` bytes that outlives the returned `&str`. pub unsafe fn to_str(&self) -> Option<&str> { - if self.ptr.is_null() || self.len == 0 { + if self.len == 0 { return Some(""); } + if self.ptr.is_null() { + return None; + } // SAFETY: Caller guarantees ptr is valid for len bytes and properly aligned. let slice = std::slice::from_raw_parts(self.ptr, self.len); std::str::from_utf8(slice).ok() @@ -325,7 +316,9 @@ impl FfiTactic { } } -/// Tactic result for FFI +/// Tactic result for FFI. +/// `message` is borrowed from the calling thread until its next +/// `rust_apply_tactic` call or thread exit. Copy it before either event. #[repr(C)] #[derive(Debug)] pub struct FfiTacticResult { @@ -361,6 +354,22 @@ pub struct FfiVerificationResult { // Global State // ============================================================================ +thread_local! { + /// Own callback messages beyond the lifetime of the temporary TacticResult. + static TACTIC_MESSAGE: RefCell = const { RefCell::new(String::new()) }; +} + +fn retain_tactic_message(message: &str) -> Result<(*const u8, u32), FfiStatus> { + let length = u32::try_from(message.len()).map_err(|_| FfiStatus::ErrorInvalidArgument)?; + let pointer = TACTIC_MESSAGE.with(|buffer| { + let mut buffer = buffer.borrow_mut(); + buffer.clear(); + buffer.push_str(message); + buffer.as_ptr() + }); + Ok((pointer, length)) +} + use std::collections::HashMap; use std::sync::Mutex; @@ -647,6 +656,9 @@ pub unsafe extern "C" fn rust_parse_string( /// Apply tactic callback for Zig FFI /// +/// The returned message is borrowed until this thread's next call to this +/// function or thread exit. Its bytes must be copied before then. +/// /// # Safety /// - `state` must be a valid pointer previously returned by `rust_parse_file`/`rust_parse_string`. /// - `tactic` must point to a valid `FfiTactic` with valid inner pointers. @@ -710,12 +722,18 @@ pub unsafe extern "C" fn rust_apply_tactic( }, }; - // SAFETY: out_result is valid per caller contract. + let (message, message_len) = match retain_tactic_message(msg) { + Ok(message) => message, + Err(status) => return status as c_int, + }; + + // SAFETY: out_result is valid per caller contract. The message + // is retained by this thread after the temporary result is dropped. unsafe { (*out_result).kind = result_kind; (*out_result)._padding = [0; 3]; - (*out_result).message_len = msg.len() as u32; - (*out_result).message = msg.as_ptr(); + (*out_result).message_len = message_len; + (*out_result).message = message; } FfiStatus::Ok as c_int @@ -939,13 +957,17 @@ pub async fn get_version(handle_id: usize) -> Result { // ffi_wrapper modules, providing a complete bridge from API → Rust core. // ============================================================================ -/// Thread-local error message buffer for `echidna_last_error()` -static LAST_ERROR: Mutex = Mutex::new(String::new()); +thread_local! { + /// Owned, NUL-terminated error message for this calling thread. + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} fn set_last_error(msg: &str) { - if let Ok(mut e) = LAST_ERROR.lock() { - *e = msg.to_string(); - } + // Error messages can contain input text. Escape embedded NUL bytes so + // C callers receive the full message instead of a truncated prefix. + let message = CString::new(msg.replace('\0', "\\0")) + .unwrap_or_else(|_| c"Invalid FFI error message".to_owned()); + LAST_ERROR.with(|error| *error.borrow_mut() = Some(message)); } /// Initialize the ECHIDNA FFI layer. Returns 0 on success, negative on error. @@ -1122,13 +1144,19 @@ pub unsafe extern "C" fn echidna_export_proof( } } -/// Get last error message. Returns null-terminated string or null if no error. +/// Get this thread's last error as a NUL-terminated string, or null if absent. +/// +/// The returned pointer is borrowed. It remains valid until the next failing +/// FFI call on the same thread or that thread exits. The caller must copy it +/// before either event and must not free it. Other threads cannot mutate it. #[no_mangle] pub extern "C" fn echidna_last_error() -> *const u8 { - match LAST_ERROR.lock() { - Ok(e) if !e.is_empty() => e.as_ptr(), - _ => ptr::null(), - } + LAST_ERROR.with(|error| { + error + .borrow() + .as_ref() + .map_or(ptr::null(), |message| message.as_ptr().cast()) + }) } /// Get ECHIDNA version string. @@ -1305,6 +1333,63 @@ pub fn kind_to_u8(kind: ProverKind) -> u8 { mod tests { use super::*; + #[test] + fn test_ffi_error_is_nul_terminated_and_escapes_embedded_nul() { + set_last_error("bad\0input"); + // SAFETY: this thread's owned buffer remains alive and unchanged + // until the next set_last_error call on this thread. + let message = unsafe { std::ffi::CStr::from_ptr(echidna_last_error().cast()) }; + assert_eq!(message.to_bytes_with_nul(), b"bad\\0input\0"); + } + + #[test] + fn test_ffi_error_is_isolated_from_other_threads() { + set_last_error("original thread"); + let pointer = echidna_last_error(); + std::thread::spawn(|| { + assert!(echidna_last_error().is_null()); + set_last_error("another thread"); + // SAFETY: this thread's buffer remains alive and unchanged. + let message = unsafe { std::ffi::CStr::from_ptr(echidna_last_error().cast()) }; + assert_eq!(message.to_bytes(), b"another thread"); + }) + .join() + .expect("other thread completes"); + // SAFETY: another thread cannot replace this thread's allocation. + let message = unsafe { std::ffi::CStr::from_ptr(pointer.cast()) }; + assert_eq!(message.to_bytes(), b"original thread"); + } + + #[test] + fn test_ffi_null_nonempty_slice_is_rejected() { + let slice = FfiStringSlice { + ptr: ptr::null(), + len: 1, + }; + // SAFETY: the invalid null pointer is rejected before dereference. + assert_eq!(unsafe { slice.to_str() }, None); + } + + #[test] + fn test_ffi_tactic_error_message_outlives_temporary_result() { + let (pointer, length) = { + let result = TacticResult::Error("owned backend error".to_string()); + let TacticResult::Error(message) = result else { + unreachable!() + }; + retain_tactic_message(&message).expect("bounded message") + }; + std::thread::spawn(|| { + retain_tactic_message("another thread's message").expect("bounded message"); + }) + .join() + .expect("other thread completes"); + // SAFETY: the message is retained on this thread, independently of + // the dropped result and of other threads' subsequent callbacks. + let message = unsafe { std::slice::from_raw_parts(pointer, length as usize) }; + assert_eq!(message, b"owned backend error"); + } + #[test] fn test_ffi_string_slice() { let s = "hello"; diff --git a/src/rust/ffi/spark_axiom.rs b/src/rust/ffi/spark_axiom.rs index 339ca418..de53e529 100644 --- a/src/rust/ffi/spark_axiom.rs +++ b/src/rust/ffi/spark_axiom.rs @@ -99,6 +99,8 @@ pub enum SparkError { UnexpectedReturn(i32), #[error("SPARK bridge not available (built without --features spark)")] NotAvailable, + #[error("SPARK/Rust policy cross-check failed: {0}")] + CrosscheckFailed(String), } /// Call the SPARK-verified `Enforce` function. diff --git a/src/rust/gnn/client.rs b/src/rust/gnn/client.rs index e637ba00..c9ae1e0b 100644 --- a/src/rust/gnn/client.rs +++ b/src/rust/gnn/client.rs @@ -200,22 +200,28 @@ pub struct GnnClient { impl GnnClient { /// Create a new GNN client with default configuration. - pub fn new() -> Self { + /// Returns an error if the HTTP client cannot be initialized. + pub fn new() -> Result { Self::with_config(GnnConfig::default()) } /// Create a new GNN client with custom configuration. - pub fn with_config(config: GnnConfig) -> Self { - let client = Client::builder() + /// Returns an error if the HTTP client cannot be initialized. + pub fn with_config(config: GnnConfig) -> Result { + Self::with_config_and_builder(config, Client::builder()) + } + + fn with_config_and_builder(config: GnnConfig, builder: reqwest::ClientBuilder) -> Result { + let client = builder .timeout(Duration::from_millis(config.timeout_ms)) .build() - .expect("Failed to create HTTP client for GNN inference"); + .context("Failed to create HTTP client for GNN inference")?; - Self { + Ok(Self { config, client, server_available: false, - } + }) } /// Fetch the richer `/gnn/health` payload introduced in S5. @@ -441,18 +447,20 @@ impl GnnClient { } /// Update configuration (rebuilds HTTP client for new timeout). - pub fn set_config(&mut self, config: GnnConfig) { - self.client = Client::builder() - .timeout(Duration::from_millis(config.timeout_ms)) - .build() - .expect("Failed to create HTTP client for GNN inference"); - self.config = config; + /// On failure, retains the previous client, configuration and health. + /// On success, a new health check is required before using the server. + pub fn set_config(&mut self, config: GnnConfig) -> Result<()> { + self.set_config_with_builder(config, Client::builder()) } -} -impl Default for GnnClient { - fn default() -> Self { - Self::new() + fn set_config_with_builder( + &mut self, + config: GnnConfig, + builder: reqwest::ClientBuilder, + ) -> Result<()> { + let replacement = Self::with_config_and_builder(config, builder)?; + *self = replacement; + Ok(()) } } @@ -474,10 +482,52 @@ mod tests { #[test] fn test_gnn_client_creation() { - let client = GnnClient::new(); + let client = GnnClient::new().expect("GNN HTTP client should initialize"); assert!(!client.is_available()); } + #[test] + fn test_http_client_build_error_is_returned() { + let error = GnnClient::with_config_and_builder( + GnnConfig::default(), + Client::builder().user_agent("invalid\nheader"), + ) + .err() + .expect("invalid HTTP headers must reject client construction"); + assert!(error.downcast_ref::().unwrap().is_builder()); + assert!(error.to_string().contains("GNN inference")); + } + + #[test] + fn test_http_client_config_failure_preserves_state() { + let mut client = GnnClient::new().expect("GNN HTTP client should initialize"); + client.server_available = true; + let replacement = GnnConfig { + api_url: "http://localhost:9091".to_string(), + timeout_ms: 25, + ..GnnConfig::default() + }; + + let error = client + .set_config_with_builder( + replacement.clone(), + Client::builder().user_agent("invalid\nheader"), + ) + .expect_err("failed rebuild must reject the update"); + assert!(error.downcast_ref::().unwrap().is_builder()); + assert_eq!(client.config().api_url, "http://localhost:8081"); + assert_eq!(client.config().timeout_ms, 10_000); + assert!(client.is_available()); + + client.set_config(replacement).expect("valid update"); + assert_eq!(client.config().api_url, "http://localhost:9091"); + assert_eq!(client.config().timeout_ms, 25); + assert!( + !client.is_available(), + "new endpoint needs its own health check" + ); + } + #[test] fn test_build_request_payload() { let state = ProofState::new(Term::App { @@ -491,7 +541,7 @@ mod tests { let mut extractor = TermFeatureExtractor::new(); extractor.extract_features(&mut graph); - let client = GnnClient::new(); + let client = GnnClient::new().expect("GNN HTTP client should initialize"); let payload = client.build_request_payload(&graph, &[]); assert_eq!(payload.graph.num_nodes, graph.num_nodes()); @@ -510,7 +560,7 @@ mod tests { #[tokio::test] async fn test_rank_premises_no_server() { - let client = GnnClient::new(); + let client = GnnClient::new().expect("GNN HTTP client should initialize"); // Server not available, should return empty result let state = ProofState::new(Term::Const("test".to_string())); let mut builder = ProofGraphBuilder::new(2); diff --git a/src/rust/gnn/guided_search.rs b/src/rust/gnn/guided_search.rs index cda739b5..0623c200 100644 --- a/src/rust/gnn/guided_search.rs +++ b/src/rust/gnn/guided_search.rs @@ -25,6 +25,7 @@ //! (`src/rust/agent/planner.rs`) as a tactic selector. It produces //! `ScoredPremise` values that the planner converts to tactics. +use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Instant; @@ -122,22 +123,24 @@ pub struct SearchStats { impl GnnGuidedSearch { /// Create a new GNN-guided search with default configuration. - pub fn new() -> Self { + /// Returns an error if the GNN HTTP client cannot be initialized. + pub fn new() -> Result { Self::with_config(GuidedSearchConfig::default()) } /// Create a new GNN-guided search with custom configuration. - pub fn with_config(config: GuidedSearchConfig) -> Self { - let gnn_client = GnnClient::with_config(config.gnn_config.clone()); + /// Returns an error if the GNN HTTP client cannot be initialized. + pub fn with_config(config: GuidedSearchConfig) -> Result { + let gnn_client = GnnClient::with_config(config.gnn_config.clone())?; let fallback_monitor = FallbackMonitor::with_config(FallbackSlaConfig::default()); - Self { + Ok(Self { config, gnn_client, feature_extractor: TermFeatureExtractor::new(), premise_embedding_cache: HashMap::new(), stats: SearchStats::default(), fallback_monitor, - } + }) } /// Check GNN server availability. Call once at startup or periodically. @@ -392,12 +395,6 @@ impl GnnGuidedSearch { } } -impl Default for GnnGuidedSearch { - fn default() -> Self { - Self::new() - } -} - /// Infer the most likely tactic for applying a theorem. /// /// Examines the theorem's name and statement to guess the appropriate @@ -542,7 +539,7 @@ mod tests { #[tokio::test] async fn test_rank_premises_without_server() { - let mut search = GnnGuidedSearch::new(); + let mut search = GnnGuidedSearch::new().expect("guided search should initialize"); let state = make_test_state(); let premises = make_test_premises(); @@ -566,7 +563,7 @@ mod tests { #[tokio::test] async fn test_all_premises_scored() { - let mut search = GnnGuidedSearch::new(); + let mut search = GnnGuidedSearch::new().expect("guided search should initialize"); let state = make_test_state(); let premises = make_test_premises(); @@ -629,14 +626,14 @@ mod tests { #[test] fn test_search_stats_tracking() { - let search = GnnGuidedSearch::new(); + let search = GnnGuidedSearch::new().expect("guided search should initialize"); assert_eq!(search.stats().total_calls, 0); assert_eq!(search.stats().gnn_calls, 0); } #[tokio::test] async fn test_stats_increment_on_call() { - let mut search = GnnGuidedSearch::new(); + let mut search = GnnGuidedSearch::new().expect("guided search should initialize"); let state = make_test_state(); let premises = make_test_premises(); diff --git a/src/rust/ipc/mod.rs b/src/rust/ipc/mod.rs index 42ada5f3..d2ad3a91 100644 --- a/src/rust/ipc/mod.rs +++ b/src/rust/ipc/mod.rs @@ -76,15 +76,14 @@ impl Transport { match self { Transport::Uds { path } => path.exists(), Transport::Tcp { host, port } => { - use std::net::TcpStream; + use std::net::{SocketAddr, TcpStream}; use std::time::Duration; - TcpStream::connect_timeout( - &format!("{}:{}", host, port) - .parse() - .unwrap_or_else(|_| "127.0.0.1:9090".parse().unwrap()), - Duration::from_millis(200), - ) - .is_ok() + let Ok(address) = format!("{}:{}", host, port).parse::() else { + // A malformed requested endpoint is unavailable. Probing a + // different fallback service would report false readiness. + return false; + }; + TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() }, } } @@ -179,6 +178,22 @@ mod tests { assert!(!t.probe()); } + #[test] + fn test_transport_tcp_probes_the_configured_address() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(Transport::Tcp { + host: "127.0.0.1".into(), + port + } + .probe()); + assert!(!Transport::Tcp { + host: "not an IP address".into(), + port + } + .probe()); + } + #[test] fn test_ipc_client_gnn_rank_stub_errors() { let rt = tokio::runtime::Runtime::new().unwrap(); diff --git a/src/rust/llm.rs b/src/rust/llm.rs index 859560f2..977238d9 100644 --- a/src/rust/llm.rs +++ b/src/rust/llm.rs @@ -222,24 +222,30 @@ pub struct LlmAdvisor { impl LlmAdvisor { /// Create a new LLM advisor with default configuration - pub fn new() -> Self { + /// Returns an error if the HTTP client cannot be initialized. + pub fn new() -> Result { Self::with_config(LlmConfig::default()) } /// Create with custom configuration - pub fn with_config(config: LlmConfig) -> Self { - let client = Client::builder() + /// Returns an error if the HTTP client cannot be initialized. + pub fn with_config(config: LlmConfig) -> Result { + Self::with_config_and_builder(config, Client::builder()) + } + + fn with_config_and_builder(config: LlmConfig, builder: reqwest::ClientBuilder) -> Result { + let client = builder .timeout(Duration::from_millis(config.timeout_ms)) .build() - .expect("Failed to create HTTP client for LLM advisor"); + .context("Failed to create HTTP client for LLM advisor")?; - Self { + Ok(Self { config, client, available: false, session_token: None, history: Vec::new(), - } + }) } /// Check if BoJ server is available and LLM is reachable @@ -598,12 +604,6 @@ impl LlmAdvisor { } } -impl Default for LlmAdvisor { - fn default() -> Self { - Self::new() - } -} - // ─── Dispatch Optimisation ────────────────────────────────────────────────── /// LLM-suggested dispatch optimisation @@ -739,10 +739,22 @@ mod tests { #[test] fn test_llm_advisor_creation() { - let advisor = LlmAdvisor::new(); + let advisor = LlmAdvisor::new().expect("LLM HTTP client should initialize"); assert!(!advisor.is_available()); } + #[test] + fn test_http_client_build_error_is_returned() { + let error = LlmAdvisor::with_config_and_builder( + LlmConfig::default(), + Client::builder().user_agent("invalid\nheader"), + ) + .err() + .expect("invalid HTTP headers must reject advisor construction"); + assert!(error.downcast_ref::().unwrap().is_builder()); + assert!(error.to_string().contains("LLM advisor")); + } + #[test] fn test_parse_prover_kind() { assert_eq!(parse_prover_kind("Lean"), Some(ProverKind::Lean)); @@ -770,7 +782,7 @@ mod tests { #[test] fn test_record_attempt() { - let mut advisor = LlmAdvisor::new(); + let mut advisor = LlmAdvisor::new().expect("LLM HTTP client should initialize"); advisor.record_attempt(ProverKind::Lean, "induction n", true, 1500); assert_eq!(advisor.history.len(), 1); assert!(advisor.history[0].succeeded); @@ -778,7 +790,7 @@ mod tests { #[tokio::test] async fn test_suggest_tactics_no_connection() { - let advisor = LlmAdvisor::new(); + let advisor = LlmAdvisor::new().expect("LLM HTTP client should initialize"); let goal = crate::core::Term::Const("test".to_string()); let state = ProofState::new(goal); let tactics = advisor.suggest_tactics(&state).await; @@ -787,7 +799,7 @@ mod tests { #[tokio::test] async fn test_rank_provers_no_connection() { - let advisor = LlmAdvisor::new(); + let advisor = LlmAdvisor::new().expect("LLM HTTP client should initialize"); let goal = crate::core::Term::Const("test".to_string()); let state = ProofState::new(goal); let ranked = advisor diff --git a/src/rust/main.rs b/src/rust/main.rs index a2b4ed4c..bd1b23aa 100644 --- a/src/rust/main.rs +++ b/src/rust/main.rs @@ -1363,7 +1363,7 @@ fn create_progress_bar(message: &str) -> ProgressBar { async fn diagnostics_command() -> Result<()> { info!("Starting diagnostics REPL for health monitoring"); let dispatcher = Arc::new(ProverDispatcher::new()); - let health = dispatcher.health_status(); + let health = dispatcher.health_status()?; let mut repl = DiagnosticsREPL::new(); repl.run(&health).await; Ok(()) diff --git a/src/rust/neural.rs b/src/rust/neural.rs index a0aa93eb..11535632 100644 --- a/src/rust/neural.rs +++ b/src/rust/neural.rs @@ -144,22 +144,31 @@ pub struct NeuralSuggester { impl NeuralSuggester { /// Create a new neural suggester with default configuration - pub fn new() -> Self { + /// Returns an error if the HTTP client cannot be initialized. + pub fn new() -> Result { Self::with_config(NeuralConfig::default()) } /// Create a new neural suggester with custom configuration - pub fn with_config(config: NeuralConfig) -> Self { - let client = Client::builder() + /// Returns an error if the HTTP client cannot be initialized. + pub fn with_config(config: NeuralConfig) -> Result { + Self::with_config_and_builder(config, Client::builder()) + } + + fn with_config_and_builder( + config: NeuralConfig, + builder: reqwest::ClientBuilder, + ) -> Result { + let client = builder .timeout(Duration::from_millis(config.timeout_ms)) .build() - .expect("Failed to create HTTP client"); + .context("Failed to create HTTP client for neural inference")?; - Self { + Ok(Self { config, client, connected: false, - } + }) } /// Check if Julia ML server is available @@ -340,13 +349,20 @@ impl NeuralSuggester { } /// Update configuration - pub fn set_config(&mut self, config: NeuralConfig) { - self.config = config; - // Rebuild client with new timeout - self.client = Client::builder() - .timeout(Duration::from_millis(self.config.timeout_ms)) - .build() - .expect("Failed to create HTTP client"); + /// On failure, retains the previous client, configuration and health. + /// On success, a new health check is required before requesting suggestions. + pub fn set_config(&mut self, config: NeuralConfig) -> Result<()> { + self.set_config_with_builder(config, Client::builder()) + } + + fn set_config_with_builder( + &mut self, + config: NeuralConfig, + builder: reqwest::ClientBuilder, + ) -> Result<()> { + let replacement = Self::with_config_and_builder(config, builder)?; + *self = replacement; + Ok(()) } /// Check if connected to Julia ML server @@ -355,12 +371,6 @@ impl NeuralSuggester { } } -impl Default for NeuralSuggester { - fn default() -> Self { - Self::new() - } -} - #[cfg(test)] mod tests { use super::*; @@ -375,18 +385,60 @@ mod tests { #[test] fn test_neural_suggester_creation() { - let suggester = NeuralSuggester::new(); + let suggester = NeuralSuggester::new().expect("neural HTTP client should initialize"); assert!(!suggester.is_connected()); assert_eq!(suggester.config().top_k, 10); } #[tokio::test] async fn test_suggest_tactics_no_connection() { - let suggester = NeuralSuggester::new(); + let suggester = NeuralSuggester::new().expect("neural HTTP client should initialize"); // Create a simple goal term for the proof state let goal = Term::Const("test_goal".to_string()); let state = ProofState::new(goal); let tactics = suggester.suggest_tactics(&state).await; assert!(tactics.is_empty()); // No connection, empty result } + + #[test] + fn test_http_client_build_error_is_returned() { + let error = NeuralSuggester::with_config_and_builder( + NeuralConfig::default(), + Client::builder().user_agent("invalid\nheader"), + ) + .err() + .expect("invalid HTTP headers must reject client construction"); + assert!(error.downcast_ref::().unwrap().is_builder()); + assert!(error.to_string().contains("neural inference")); + } + + #[test] + fn test_http_client_config_failure_preserves_state() { + let mut suggester = NeuralSuggester::new().expect("neural HTTP client should initialize"); + suggester.connected = true; + let replacement = NeuralConfig { + api_url: "http://localhost:9091".to_string(), + timeout_ms: 25, + ..NeuralConfig::default() + }; + + let error = suggester + .set_config_with_builder( + replacement.clone(), + Client::builder().user_agent("invalid\nheader"), + ) + .expect_err("failed rebuild must reject the update"); + assert!(error.downcast_ref::().unwrap().is_builder()); + assert_eq!(suggester.config().api_url, "http://localhost:8081"); + assert_eq!(suggester.config().timeout_ms, 5000); + assert!(suggester.is_connected()); + + suggester.set_config(replacement).expect("valid update"); + assert_eq!(suggester.config().api_url, "http://localhost:9091"); + assert_eq!(suggester.config().timeout_ms, 25); + assert!( + !suggester.is_connected(), + "new endpoint needs its own health check" + ); + } } diff --git a/src/rust/provers/framac.rs b/src/rust/provers/framac.rs index d2bc2319..191e2d02 100644 --- a/src/rust/provers/framac.rs +++ b/src/rust/provers/framac.rs @@ -103,11 +103,13 @@ impl FramaCBackend { } // Detect start of multi-line ACSL block: /*@ ... */ - if trimmed.contains("/*@") { + if let Some(start_idx) = trimmed.find("/*@") { + let annotation = &trimmed[start_idx + 3..]; // Check if it's a single-line block: /*@ ... */ - if let Some(end_idx) = trimmed.find("*/") { - let start_idx = trimmed.find("/*@").unwrap(); - let acsl_text = &trimmed[start_idx + 3..end_idx]; + // Only a closing delimiter after this opening delimiter + // belongs to the annotation; earlier C comments may precede it. + if let Some(end_idx) = annotation.find("*/") { + let acsl_text = &annotation[..end_idx]; for clause in acsl_text.split(';') { let clause = clause.trim(); if !clause.is_empty() { @@ -122,17 +124,15 @@ impl FramaCBackend { } else { // Start of multi-line block in_acsl_block = true; - let start_idx = trimmed.find("/*@").unwrap(); - acsl_buffer = trimmed[start_idx + 3..].to_string(); + acsl_buffer = annotation.to_string(); } continue; } // Inside a multi-line ACSL block if in_acsl_block { - if trimmed.contains("*/") { + if let Some(end_idx) = trimmed.find("*/") { // End of multi-line block - let end_idx = trimmed.find("*/").unwrap(); acsl_buffer.push(' '); acsl_buffer.push_str(&trimmed[..end_idx]); in_acsl_block = false; @@ -173,6 +173,10 @@ impl FramaCBackend { } } + if in_acsl_block { + return Err(anyhow!("Unterminated ACSL annotation block")); + } + // Store the WP prover and timeout in metadata for roundtripping state.metadata.insert( "framac_wp_prover".to_string(), @@ -857,6 +861,22 @@ mod tests { FramaCBackend::new(config) } + #[test] + fn test_acsl_after_an_ordinary_comment() { + let state = test_backend() + .parse_acsl_source("/* ordinary C comment */ /*@ requires x > 0; */") + .expect("the annotation closes after its own opening delimiter"); + assert_eq!(state.goals.len(), 1); + } + + #[test] + fn test_unterminated_acsl_is_an_error() { + let error = test_backend() + .parse_acsl_source("*/ /*@ requires x > 0;") + .expect_err("an earlier closing delimiter cannot close this annotation"); + assert!(error.to_string().contains("Unterminated ACSL")); + } + #[tokio::test] async fn test_parse_acsl_function_contract() { let backend = test_backend(); diff --git a/src/rust/provers/lean.rs b/src/rust/provers/lean.rs index 99ea788b..b08bd580 100644 --- a/src/rust/provers/lean.rs +++ b/src/rust/provers/lean.rs @@ -155,11 +155,17 @@ impl LeanBackend { } /// Generate a fresh metavariable name - fn fresh_meta(&self) -> String { - let mut counter = self.meta_counter.lock().unwrap(); + fn fresh_meta(&self) -> Result { + let mut counter = self + .meta_counter + .lock() + .map_err(|_| anyhow!("Lean metavariable counter is poisoned"))?; + let next = counter + .checked_add(1) + .ok_or_else(|| anyhow!("Lean metavariable counter exhausted"))?; let name = format!("?m{}", *counter); - *counter += 1; - name + *counter = next; + Ok(name) } /// Run Lean on a file and capture output @@ -548,7 +554,7 @@ impl LeanBackend { if let Some(goal) = current_goal.take() { goals.push(goal); } - let id = self.fresh_meta(); + let id = self.fresh_meta()?; current_goal = Some(LeanGoal { id, context: Vec::new(), @@ -586,7 +592,7 @@ impl LeanBackend { // Handle basic cases if input.is_empty() { - return Ok(LeanExpr::MVar(self.fresh_meta())); + return Ok(LeanExpr::MVar(self.fresh_meta()?)); } // Check for function types @@ -1663,15 +1669,34 @@ mod tests { let config = ProverConfig::default(); let backend = LeanBackend::new(config); - let m1 = backend.fresh_meta(); - let m2 = backend.fresh_meta(); - let m3 = backend.fresh_meta(); + let m1 = backend.fresh_meta().unwrap(); + let m2 = backend.fresh_meta().unwrap(); + let m3 = backend.fresh_meta().unwrap(); assert_eq!(m1, "?m0"); assert_eq!(m2, "?m1"); assert_eq!(m3, "?m2"); } + #[test] + fn test_fresh_meta_rejects_poison_and_exhaustion() { + let poisoned = LeanBackend::new(ProverConfig::default()); + let outcome = std::panic::catch_unwind(|| { + let _guard = poisoned + .meta_counter + .lock() + .expect("initially healthy lock"); + panic!("deliberate poison regression fixture"); + }); + assert!(outcome.is_err()); + assert!(poisoned.fresh_meta().is_err()); + + let exhausted = LeanBackend::new(ProverConfig::default()); + *exhausted.meta_counter.lock().unwrap() = usize::MAX; + assert!(exhausted.fresh_meta().is_err()); + assert_eq!(*exhausted.meta_counter.lock().unwrap(), usize::MAX); + } + #[test] fn test_lean_expr_to_term() { let config = ProverConfig::default(); diff --git a/src/rust/provers/mod.rs b/src/rust/provers/mod.rs index a9e78bd3..408ba12a 100644 --- a/src/rust/provers/mod.rs +++ b/src/rust/provers/mod.rs @@ -216,8 +216,9 @@ impl Default for ProverConfig { /// GNN-augmented tactic suggestions: calls the Julia GNN server to rank premises, /// then prepends top-k `Apply` and prover-specific `apply` tactics to the list. -/// Falls back silently (returns `hints` unchanged) if the server is unreachable -/// or `neural_enabled` is false or `gnn_api_url` is unset. +/// Returns the bounded heuristic hints if the optional server is unreachable, +/// neural guidance is disabled or no GNN URL is configured. Client setup errors +/// are logged and also retain the heuristic path without claiming GNN results. /// /// # Arguments /// * `config` – prover config supplying the GNN URL and neural flag @@ -242,7 +243,7 @@ pub(crate) async fn gnn_augment_tactics( use crate::gnn::graph::ProofGraphBuilder; let graph = ProofGraphBuilder::new(4).build_from_proof_state(state); - let mut gnn = GnnClient::with_config(GnnConfig { + let mut gnn = match GnnClient::with_config(GnnConfig { api_url: url, timeout_ms: 2000, top_k: 8, @@ -250,7 +251,13 @@ pub(crate) async fn gnn_augment_tactics( request_embeddings: false, num_gnn_layers: 3, use_attention: true, - }); + }) { + Ok(client) => client, + Err(error) => { + tracing::warn!(%error, "GNN client setup failed; retaining heuristic tactics"); + return hints.into_iter().take(limit).collect(); + }, + }; // Extract goal aspects from state metadata (written by AgentCore::process_goal) // so Julia's /gnn/rank can apply per-domain weights from prior outcomes. diff --git a/src/rust/provers/opensmt.rs b/src/rust/provers/opensmt.rs index d1b44cd0..6c6a5d7b 100644 --- a/src/rust/provers/opensmt.rs +++ b/src/rust/provers/opensmt.rs @@ -11,9 +11,8 @@ #![allow(dead_code)] -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use async_trait::async_trait; -use std::os::unix::process::ExitStatusExt; use std::path::PathBuf; use std::process::Stdio; use tokio::process::Command; @@ -62,17 +61,35 @@ impl ProverBackend for OpenSmtBackend { } async fn version(&self) -> Result { - let output = Command::new(&self.config.executable) - .arg("--version") - .output() - .await - .unwrap_or_else(|_| std::process::Output { - status: std::process::ExitStatus::from_raw(1), - stdout: b"OpenSMT (version unknown)".to_vec(), - stderr: vec![], - }); - - Ok(String::from_utf8_lossy(&output.stdout).to_string()) + let output = tokio::time::timeout( + std::time::Duration::from_secs(self.config.timeout.max(1)), + Command::new(&self.config.executable) + .arg("--version") + .kill_on_drop(true) + .output(), + ) + .await + .context("OpenSMT version probe timed out")? + .with_context(|| { + format!( + "Failed to execute OpenSMT at {}", + self.config.executable.display() + ) + })?; + if !output.status.success() { + bail!("OpenSMT version probe failed: {}", output.status); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let version = if stdout.trim().is_empty() { + stderr.trim() + } else { + stdout.trim() + }; + if version.is_empty() { + bail!("OpenSMT version probe returned no version information"); + } + Ok(version.to_owned()) } async fn parse_file(&self, path: PathBuf) -> Result { @@ -188,6 +205,16 @@ mod tests { assert_eq!(backend.kind(), ProverKind::OpenSmt); } + #[tokio::test] + async fn test_version_rejects_a_missing_executable() { + let directory = tempfile::tempdir().unwrap(); + let backend = OpenSmtBackend::new(ProverConfig { + executable: directory.path().join("missing-opensmt"), + ..ProverConfig::default() + }); + assert!(backend.version().await.is_err()); + } + #[test] fn test_parse_result_unsat() { let config = ProverConfig::default(); diff --git a/src/rust/provers/spass.rs b/src/rust/provers/spass.rs index 7e98a7bf..959edadb 100644 --- a/src/rust/provers/spass.rs +++ b/src/rust/provers/spass.rs @@ -14,7 +14,7 @@ #![allow(dead_code)] -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use async_trait::async_trait; use std::path::PathBuf; use std::process::Stdio; @@ -33,6 +33,25 @@ impl SPASSBackend { SPASSBackend { config } } + fn version_banner(output: &str) -> Option { + output.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let executable = fields.next()?.rsplit('/').next()?; + let marker = fields.next()?; + let version = fields.next()?; + if executable.eq_ignore_ascii_case("SPASS") + && matches!(marker, "V" | "Version") + && version + .split('.') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + { + Some(format!("SPASS {marker} {version}")) + } else { + None + } + }) + } + /// Convert proof state to SPASS DFG format fn to_dfg(&self, state: &ProofState) -> Result { let mut dfg = String::new(); @@ -89,18 +108,30 @@ impl ProverBackend for SPASSBackend { } async fn version(&self) -> Result { - let output = Command::new(&self.config.executable) - .output() - .await - .context("Failed to run SPASS")?; + let output = tokio::time::timeout( + std::time::Duration::from_secs(self.config.timeout.clamp(1, 30)), + Command::new(&self.config.executable) + .stdin(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .context("SPASS version probe timed out")? + .context("Failed to run SPASS")?; let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if line.contains("SPASS") && line.contains("Version") { - return Ok(line.trim().to_string()); - } + let stderr = String::from_utf8_lossy(&output.stderr); + // Ubuntu's SPASS 3.9 emits its real banner plus usage, then exits 1 + // when invoked without an input file. This is its identification + // interface, not a successful proof attempt. Other errors still fail. + let usage_exit = output.status.code() == Some(1) + && (stdout.contains("Usage: SPASS") || stderr.contains("Usage: SPASS")); + if !output.status.success() && !usage_exit { + bail!("SPASS version probe failed: {}", output.status); } - Ok("unknown".to_string()) + Self::version_banner(&stdout) + .or_else(|| Self::version_banner(&stderr)) + .context("SPASS did not return a recognized version banner") } async fn parse_file(&self, path: PathBuf) -> Result { @@ -265,6 +296,37 @@ impl ProverBackend for SPASSBackend { mod tests { use super::*; + #[test] + fn test_spass_version_banner_matches_real_usage_output() { + assert_eq!( + SPASSBackend::version_banner("\n\t/usr/bin/SPASS V 3.9\nUsage: SPASS [options]\n"), + Some("SPASS V 3.9".to_string()) + ); + assert_eq!( + SPASSBackend::version_banner("SPASS Version 3.7"), + Some("SPASS Version 3.7".to_string()) + ); + for unrelated in [ + "", + "SPASS V unknown", + "OtherTool V 3.9", + "error: SPASS V 3.9", + "SPASS V 3..9", + ] { + assert!(SPASSBackend::version_banner(unrelated).is_none()); + } + } + + #[tokio::test] + async fn test_spass_version_rejects_a_missing_executable() { + let directory = tempfile::tempdir().unwrap(); + let backend = SPASSBackend::new(ProverConfig { + executable: directory.path().join("missing-SPASS"), + ..ProverConfig::default() + }); + assert!(backend.version().await.is_err()); + } + #[test] fn test_spass_kind() { let config = ProverConfig { diff --git a/src/rust/repl/diagnostics.rs b/src/rust/repl/diagnostics.rs index 1d881eab..cde4ef5d 100644 --- a/src/rust/repl/diagnostics.rs +++ b/src/rust/repl/diagnostics.rs @@ -26,10 +26,14 @@ impl DiagnosticsREPL { loop { print!("{}", self.prompt); - io::stdout().flush().unwrap(); + if let Err(error) = io::stdout().flush() { + eprintln!("Cannot write diagnostics prompt: {error}"); + break; + } let mut input = String::new(); match io::stdin().read_line(&mut input) { + Ok(0) => break, Ok(_) => { let trimmed = input.trim(); if !trimmed.is_empty() { @@ -51,7 +55,10 @@ impl DiagnosticsREPL { "corpus" => self.print_corpus_status(health), "clear" => { print!("\x1B[2J\x1B[1;1H"); - io::stdout().flush().unwrap(); + if let Err(error) = io::stdout().flush() { + eprintln!("Cannot clear diagnostics terminal: {error}"); + break; + } }, "history" => self.print_history(), cmd => { diff --git a/src/rust/server.rs b/src/rust/server.rs index c62a6a97..df6b4b0d 100644 --- a/src/rust/server.rs +++ b/src/rust/server.rs @@ -395,9 +395,14 @@ async fn health_check() -> Json { /// Detailed diagnostics health endpoint /// Returns full HealthStatus snapshot from dispatcher with prover metrics and degradation mode -async fn diagnostics_health_handler(State(app_state): State) -> Json { - let health = app_state.dispatcher.health_status(); - Json(health) +async fn diagnostics_health_handler( + State(app_state): State, +) -> Result, AppError> { + let health = app_state + .dispatcher + .health_status() + .map_err(|error| AppError::InternalError(error.to_string()))?; + Ok(Json(health)) } /// List available provers @@ -471,7 +476,8 @@ async fn prove_handler( }; app_state .dispatcher - .record_prover_result(req.prover, &outcome, 0); + .record_prover_result(req.prover, &outcome, 0) + .map_err(|error| AppError::InternalError(error.to_string()))?; return Ok(Json(ProveResponse { success: false, goals: 0, @@ -501,7 +507,8 @@ async fn prove_handler( }; app_state .dispatcher - .record_prover_result(req.prover, &outcome, verify_elapsed_ms); + .record_prover_result(req.prover, &outcome, verify_elapsed_ms) + .map_err(|error| AppError::InternalError(error.to_string()))?; Ok(Json(ProveResponse { success: valid, diff --git a/src/rust/verification/axiom_tracker.rs b/src/rust/verification/axiom_tracker.rs index 5807d877..136f39e0 100644 --- a/src/rust/verification/axiom_tracker.rs +++ b/src/rust/verification/axiom_tracker.rs @@ -378,10 +378,11 @@ impl AxiomTracker { } /// SPARK cross-check: run enforce_policy in Rust, then call the SPARK - /// bridge and assert both agree. Active only under `--features spark`. + /// bridge and require both to agree. Active only under `--features spark`. /// - /// Returns the Rust result on success; panics in debug builds if the - /// results diverge (indicates a SPARK/Rust synchronisation bug). + /// Returns the Rust result only after a successful cross-check. A bridge + /// failure or disagreement is an error in every build, never a successful + /// policy decision or a process-wide panic. #[cfg(feature = "spark")] pub fn enforce_policy_with_spark_crosscheck( &self, @@ -389,12 +390,8 @@ impl AxiomTracker { ) -> Result { let rust_policy = self.enforce_policy(usages); - crate::ffi::spark_axiom::crosscheck_enforce_policy(usages, &rust_policy).unwrap_or_else( - |e| { - // Divergence is a critical soundness bug — abort loudly. - panic!("SPARK/Rust axiom-policy divergence (soundness violation): {e}"); - }, - ); + crate::ffi::spark_axiom::crosscheck_enforce_policy(usages, &rust_policy) + .map_err(crate::ffi::spark_axiom::SparkError::CrosscheckFailed)?; Ok(rust_policy) } diff --git a/src/zig/ffi/echidna_ffi.h b/src/zig/ffi/echidna_ffi.h index 34447052..1c1cc7b2 100644 --- a/src/zig/ffi/echidna_ffi.h +++ b/src/zig/ffi/echidna_ffi.h @@ -156,7 +156,11 @@ typedef struct { const uint8_t* arg; } EchidnaTactic; -/** Tactic result */ +/** Tactic result. + * A message from the Rust callback is borrowed until the next tactic callback + * on the same thread or thread exit. Copy message_len bytes before then; + * the message need not be NUL-terminated and must not be freed by the caller. + */ typedef struct { EchidnaTacticResultKind kind; uint8_t _padding[3]; diff --git a/src/zig/ffi/echidna_ffi.zig b/src/zig/ffi/echidna_ffi.zig index 064c71dc..5a56e96f 100644 --- a/src/zig/ffi/echidna_ffi.zig +++ b/src/zig/ffi/echidna_ffi.zig @@ -186,7 +186,9 @@ pub const Tactic = extern struct { } }; -/// Tactic result for FFI +/// Tactic result for FFI. A Rust callback's message is borrowed until the +/// next tactic callback on the same thread or thread exit. Copy it before +/// then; do not free it or assume NUL termination. pub const TacticResult = extern struct { kind: TacticResultKind, _padding: [3]u8 = [_]u8{0} ** 3, diff --git a/tests/corpus_hazard_fixtures.rs b/tests/corpus_hazard_fixtures.rs new file mode 100644 index 00000000..da8c54c0 --- /dev/null +++ b/tests/corpus_hazard_fixtures.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The intentionally incomplete corpus inputs are detector controls, never +//! proof receipts. Exercise the checked-in files through the real adapters. + +use std::path::Path; + +use echidna::corpus::{isabelle, minif2f, Corpus}; + +fn assert_hazard(corpus: &Corpus, name: &str, expected: bool) { + let entries: Vec<_> = corpus + .entries + .iter() + .filter(|entry| entry.name == name) + .collect(); + assert_eq!( + entries.len(), + 1, + "expected exactly one fixture declaration: {name}" + ); + assert_eq!( + entries[0].axiom_usage.sorry, expected, + "hazard classification: {name}" + ); +} + +#[test] +fn isabelle_fixture_keeps_incomplete_proofs_unqualified() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus_fixtures/isabelle"); + let corpus = isabelle::ingest(&root).expect("parse checked-in Isabelle fixture"); + assert_hazard(&corpus, "double_mono", true); + assert_hazard(&corpus, "double_zero", false); +} + +#[test] +fn minif2f_fixture_keeps_incomplete_proofs_unqualified() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus_fixtures/minif2f"); + let corpus = minif2f::ingest(&root).expect("parse checked-in MiniF2F fixture"); + assert_hazard(&corpus, "imo_1959_p1", true); + assert_hazard(&corpus, "mathd_algebra_42", false); +} diff --git a/tests/fallback_monitor_integration_test.rs b/tests/fallback_monitor_integration_test.rs index 259b09cf..1c199f4b 100644 --- a/tests/fallback_monitor_integration_test.rs +++ b/tests/fallback_monitor_integration_test.rs @@ -40,7 +40,7 @@ async fn test_fallback_monitor_sla_compliance() { #[tokio::test] async fn test_guided_search_integrates_fallback_monitor() { - let mut search = GnnGuidedSearch::new(); + let mut search = GnnGuidedSearch::new().expect("guided search should initialize"); // Create a simple proof state let state = ProofState { diff --git a/tests/gnn_augment_integration.rs b/tests/gnn_augment_integration.rs index a87ad290..673080a4 100644 --- a/tests/gnn_augment_integration.rs +++ b/tests/gnn_augment_integration.rs @@ -104,7 +104,8 @@ async fn test_health_status_richer_payload() { api_url: base_url, timeout_ms: 5000, ..GnnConfig::default() - }); + }) + .expect("GNN HTTP client should initialize"); let health = client .health_status() diff --git a/tests/prove_handler_health_integration_test.rs b/tests/prove_handler_health_integration_test.rs index 988f2245..93ab0394 100644 --- a/tests/prove_handler_health_integration_test.rs +++ b/tests/prove_handler_health_integration_test.rs @@ -15,7 +15,7 @@ mod prove_handler_health_tests { let dispatcher = Arc::new(ProverDispatcher::new()); // Initial health: no provers tracked - let initial_health = dispatcher.health_status(); + let initial_health = dispatcher.health_status().expect("healthy dispatcher"); assert_eq!(initial_health.prover_health.len(), 0); // GNN is not loaded by default, so degradation is CosineOnly assert_eq!( @@ -26,10 +26,12 @@ mod prove_handler_health_tests { // Record a successful proof for Z3 use echidna::provers::ProverOutcome; let z3_success = ProverOutcome::Proved { elapsed_ms: 45 }; - dispatcher.record_prover_result(ProverKind::Z3, &z3_success, 45); + dispatcher + .record_prover_result(ProverKind::Z3, &z3_success, 45) + .expect("record prover result"); // After recording, health should track Z3 - let updated_health = dispatcher.health_status(); + let updated_health = dispatcher.health_status().expect("healthy dispatcher"); assert_eq!(updated_health.prover_health.len(), 1); assert!(updated_health.prover_health.contains_key("Z3")); @@ -47,10 +49,12 @@ mod prove_handler_health_tests { elapsed_ms: 120, reason: Some("No proof found".to_string()), }; - dispatcher.record_prover_result(ProverKind::Coq, &coq_failure, 120); + dispatcher + .record_prover_result(ProverKind::Coq, &coq_failure, 120) + .expect("record prover result"); // After second recording, health tracks both - let final_health = dispatcher.health_status(); + let final_health = dispatcher.health_status().expect("healthy dispatcher"); assert_eq!(final_health.prover_health.len(), 2); let coq_health = &final_health.prover_health["Coq"]; @@ -87,10 +91,12 @@ mod prove_handler_health_tests { detail: format!("Prover crash {}", i), exit_code: Some(1), }; - dispatcher.record_prover_result(ProverKind::Lean, &failure, 30); + dispatcher + .record_prover_result(ProverKind::Lean, &failure, 30) + .expect("record prover result"); } - let health = dispatcher.health_status(); + let health = dispatcher.health_status().expect("healthy dispatcher"); let lean_health = &health.prover_health["Lean"]; // After 3 failures, is_available should be false @@ -113,18 +119,22 @@ mod prove_handler_health_tests { // First invocation: 50ms let outcome1 = ProverOutcome::Proved { elapsed_ms: 50 }; - dispatcher.record_prover_result(ProverKind::Isabelle, &outcome1, 50); + dispatcher + .record_prover_result(ProverKind::Isabelle, &outcome1, 50) + .expect("record prover result"); - let health1 = dispatcher.health_status(); + let health1 = dispatcher.health_status().expect("healthy dispatcher"); let isabelle1 = &health1.prover_health["Isabelle"]; // EMA: 0.8 * 0.0 + 0.2 * 50.0 = 10.0 assert!((isabelle1.avg_latency_ms - 10.0).abs() < 0.1); // Second invocation: 100ms let outcome2 = ProverOutcome::Proved { elapsed_ms: 100 }; - dispatcher.record_prover_result(ProverKind::Isabelle, &outcome2, 100); + dispatcher + .record_prover_result(ProverKind::Isabelle, &outcome2, 100) + .expect("record prover result"); - let health2 = dispatcher.health_status(); + let health2 = dispatcher.health_status().expect("healthy dispatcher"); let isabelle2 = &health2.prover_health["Isabelle"]; // EMA: 0.8 * 10.0 + 0.2 * 100.0 = 8.0 + 20.0 = 28.0 assert!((isabelle2.avg_latency_ms - 28.0).abs() < 0.1); @@ -149,10 +159,12 @@ mod prove_handler_health_tests { _ => ProverKind::Z3, }; let outcome = ProverOutcome::Proved { elapsed_ms: 50 }; - dispatcher.record_prover_result(kind, &outcome, 50); + dispatcher + .record_prover_result(kind, &outcome, 50) + .expect("record prover result"); } - let health = dispatcher.health_status(); + let health = dispatcher.health_status().expect("healthy dispatcher"); let health_pct = health.health_percentage(); // With all provers available and success_rate ~0.5 default, health should be good diff --git a/tests/test_neural_integration.rs b/tests/test_neural_integration.rs index 26256971..f0d6ee38 100644 --- a/tests/test_neural_integration.rs +++ b/tests/test_neural_integration.rs @@ -26,7 +26,7 @@ async fn test_julia_health_check() -> Result<()> { use_cache: true, }; - let mut suggester = NeuralSuggester::with_config(config); + let mut suggester = NeuralSuggester::with_config(config)?; let healthy = suggester.check_health().await?; assert!(healthy, "Julia server should report healthy"); @@ -51,7 +51,7 @@ async fn test_julia_predict_premises() -> Result<()> { use_cache: false, }; - let mut suggester = NeuralSuggester::with_config(config); + let mut suggester = NeuralSuggester::with_config(config)?; // First connect let healthy = suggester.check_health().await?; @@ -90,7 +90,7 @@ async fn test_julia_suggest_tactics() -> Result<()> { use_cache: true, }; - let mut suggester = NeuralSuggester::with_config(config); + let mut suggester = NeuralSuggester::with_config(config)?; // First connect let healthy = suggester.check_health().await?; @@ -121,7 +121,7 @@ async fn test_julia_suggest_tactics_scored() -> Result<()> { use_cache: false, }; - let mut suggester = NeuralSuggester::with_config(config); + let mut suggester = NeuralSuggester::with_config(config)?; // First connect suggester.check_health().await?; @@ -156,7 +156,7 @@ async fn test_connection_failure_graceful() -> Result<()> { use_cache: false, }; - let mut suggester = NeuralSuggester::with_config(config); + let mut suggester = NeuralSuggester::with_config(config)?; // Health check should fail gracefully let healthy = suggester.check_health().await?; @@ -188,7 +188,7 @@ async fn test_config_update() -> Result<()> { use_cache: true, }; - let mut suggester = NeuralSuggester::with_config(initial_config); + let mut suggester = NeuralSuggester::with_config(initial_config)?; assert_eq!(suggester.config().top_k, 5); assert_eq!(suggester.config().timeout_ms, 1000); @@ -204,7 +204,7 @@ async fn test_config_update() -> Result<()> { use_cache: false, }; - suggester.set_config(new_config); + suggester.set_config(new_config)?; assert_eq!(suggester.config().top_k, 20); assert_eq!(suggester.config().timeout_ms, 3000); @@ -226,7 +226,7 @@ async fn test_multiple_provers() -> Result<()> { use_cache: false, }; - let mut suggester = NeuralSuggester::with_config(config); + let mut suggester = NeuralSuggester::with_config(config)?; suggester.check_health().await?; let goal = Term::Const("test".to_string()); From 0b37fed9f59f55b3ddd7dd2319abd94f08ff2130 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:19:25 +0000 Subject: [PATCH 09/30] Update docs/reports/2026-11-04-agda-meta-checker-receipt.json Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- docs/reports/2026-11-04-agda-meta-checker-receipt.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reports/2026-11-04-agda-meta-checker-receipt.json b/docs/reports/2026-11-04-agda-meta-checker-receipt.json index 46784c16..71d5a820 100644 --- a/docs/reports/2026-11-04-agda-meta-checker-receipt.json +++ b/docs/reports/2026-11-04-agda-meta-checker-receipt.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "spdx_license_id": "MPL-2.0", + "spdx_license_id": "CC-BY-SA-4.0", "kind": "local-agda-model-check", "recorded_at_utc": "2026-11-04T17:26:43Z", "source": { From 32c1c26c765770ef5e65d2d80846fdc6e3d6c54e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:35:10 +0000 Subject: [PATCH 10/30] Update .github/workflows/workflow-linter.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/workflow-linter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 8966ac15..68fc19ad 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -95,7 +95,7 @@ jobs: outcome=$(regen_lock "$subject" 2> "$RUNNER_TEMP/actions-lock-refresh.log") cat "$RUNNER_TEMP/actions-lock-refresh.log" case "$outcome" in - changed|unchanged) ;; + changed|current) ;; *) echo "::error::Canonical lock generation failed: $outcome"; exit 2 ;; esac git -C "$subject" diff --quiet -- . ':(exclude).github/workflows/actions.lock' From 4421fe836dc1c7e848866c30c5ae674af0908ed3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:36:16 +0000 Subject: [PATCH 11/30] Update scripts/ci/static-analysis-evidence.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/ci/static-analysis-evidence.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci/static-analysis-evidence.sh b/scripts/ci/static-analysis-evidence.sh index f27e599e..905e8b12 100755 --- a/scripts/ci/static-analysis-evidence.sh +++ b/scripts/ci/static-analysis-evidence.sh @@ -170,7 +170,9 @@ qualified_receipt() { and .provenance.repository == $repo and .provenance.commit_sha == $sha and .provenance.tree_sha == $tree and .provenance.worktree_clean == true and .provenance.expected_sha == $sha and .provenance.source_sha == $ref - and .provenance.run_id == $run and .provenance.run_attempt == $attempt + and .provenance.run_id == $run + and (.provenance.run_attempt | test("^[1-9][0-9]*$")) + and ((.provenance.run_attempt | tonumber) <= ($attempt | tonumber)) and (.provenance.binary_sha256 | test("^[0-9a-f]{64}$"))' "$receipt" >/dev/null 2>&1 || return 1 jq '.report' "$receipt" > "$report" || return 1 valid_report "$tool" "$report" || return 1 From 2f152570d6e09c48885574e1c8ba897acf751d9a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:36:53 +0000 Subject: [PATCH 12/30] Update src/rust/provers/opensmt.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- src/rust/provers/opensmt.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rust/provers/opensmt.rs b/src/rust/provers/opensmt.rs index 6c6a5d7b..d1083b33 100644 --- a/src/rust/provers/opensmt.rs +++ b/src/rust/provers/opensmt.rs @@ -62,7 +62,7 @@ impl ProverBackend for OpenSmtBackend { async fn version(&self) -> Result { let output = tokio::time::timeout( - std::time::Duration::from_secs(self.config.timeout.max(1)), + std::time::Duration::from_secs(self.config.timeout.clamp(1, 30)), Command::new(&self.config.executable) .arg("--version") .kill_on_drop(true) From 28f252818bddc932586de2dcfc5a6f5b495a54f6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:41:40 +0000 Subject: [PATCH 13/30] fix(ci): qualify scanner updates and close static quality findings Canary exact Hypatia source 82396d8 for Agda keyword boundaries and patched dependencies in all three scanner paths; pin the secret scanner to published a12b2b4 for namespace compatibility while preserving existing contexts. Restrict curl protocols and redirects to HTTPS, retain digest-only container references, and use explicit Bash receipt branches. Preserve concurrent OpenSMT timeout, canonical generator outcome and partial rerun fixes. Add five receipt controls for reuse of an earlier attempt from the same exact run and source, rejecting future/invalid attempts or another run. All 31 receipt controls, actionlint and syntax checks pass. Official lock regeneration follows from this exact source subject; scanner completion and findings remain independently enforced. --- .github/workflows/governance.yml | 3 ++ .github/workflows/hypatia-scan.yml | 2 + .github/workflows/live-provers.yml | 24 +++++------ .github/workflows/secret-scanner.yml | 2 +- .github/workflows/static-analysis-gate.yml | 5 ++- container/Containerfile | 4 +- scripts/ci/install-cargo-audit.sh | 2 +- scripts/ci/static-analysis-evidence.sh | 45 +++++++++++---------- scripts/ci/test-static-analysis-evidence.sh | 27 +++++++++---- 9 files changed, 67 insertions(+), 47 deletions(-) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 9772bd80..5869ce30 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -19,3 +19,6 @@ permissions: jobs: governance: uses: metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # published source, 2026-11-04 + with: + # Qualified source canary; the shared default remains independently vetted. + hypatia-ref: 82396d81677b11d17c7a0dede5bf0efcf768d5a1 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index e8a9eb8b..d0f1dd38 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -25,5 +25,7 @@ jobs: contents: read security-events: write uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@d3e4b485a2af90c92a8e18af0888cd19cb132cd9 # published source, 2026-11-04 + with: + hypatia-ref: 82396d81677b11d17c7a0dede5bf0efcf768d5a1 secrets: HYPATIA_SCAN_PAT: ${{ secrets.HYPATIA_SCAN_PAT }} diff --git a/.github/workflows/live-provers.yml b/.github/workflows/live-provers.yml index 0903a499..8648ed04 100644 --- a/.github/workflows/live-provers.yml +++ b/.github/workflows/live-provers.yml @@ -95,14 +95,14 @@ jobs: z3) sudo apt-get install -y --fix-missing z3 ;; cvc5) # Since cvc5 1.2.0 releases ship as a zip with the binary at # bin/cvc5; the old bare `cvc5-Linux-static` asset no longer exists. - curl -sSL --max-time 120 -o /tmp/cvc5.zip \ + curl --proto '=https' --proto-redir '=https' -sSL --max-time 120 -o /tmp/cvc5.zip \ https://github.com/cvc5/cvc5/releases/download/cvc5-1.3.3/cvc5-Linux-x86_64-static.zip unzip -j -o /tmp/cvc5.zip 'cvc5-Linux-x86_64-static/bin/cvc5' -d /tmp sudo install -m 0755 /tmp/cvc5 /usr/local/bin/cvc5 ;; vampire) # Asset was renamed from vampire-Linux-x86_64.zip to # vampire-Linux-X64.zip starting with v5.x; zip contains # a single file named `vampire` at the root. - curl -sSL --max-time 120 -o /tmp/vampire.zip \ + curl --proto '=https' --proto-redir '=https' -sSL --max-time 120 -o /tmp/vampire.zip \ https://github.com/vprover/vampire/releases/download/v5.0.1/vampire-Linux-X64.zip unzip -j -o /tmp/vampire.zip -d /tmp sudo install -m 0755 /tmp/vampire /usr/local/bin/vampire ;; @@ -111,7 +111,7 @@ jobs: alt-ergo) # alt-ergo is not in the Ubuntu 24.04 (noble) apt repos. # Install the prebuilt musl static binary from OCamlPro's # GitHub releases instead. - curl -sSL --max-time 120 -o /tmp/alt-ergo \ + curl --proto '=https' --proto-redir '=https' -sSL --max-time 120 -o /tmp/alt-ergo \ https://github.com/OCamlPro/alt-ergo/releases/download/v2.6.3/alt-ergo-v2.6.3-x86_64-linux-musl sudo install -m 0755 /tmp/alt-ergo /usr/local/bin/alt-ergo ;; glpk) sudo apt-get install -y --fix-missing glpk-utils ;; @@ -119,7 +119,7 @@ jobs: chuffed) # Ubuntu's minizinc package does not include fzn-chuffed. # The upstream bundle does; authenticate it before extraction. bundle="$RUNNER_TEMP/MiniZincIDE-2.10.1-x86_64-linux-gnu.tgz" - curl --fail --location --retry 3 --max-time 120 --output "$bundle" \ + curl --proto '=https' --proto-redir '=https' --fail --location --retry 3 --max-time 120 --output "$bundle" \ https://github.com/MiniZinc/MiniZincIDE/releases/download/2.10.1/MiniZincIDE-2.10.1-x86_64-linux-gnu.tgz printf '%s %s\n' b63dd88491d47b05d0eb0ff4aa1112d1e6f77292c023800b20b98851458ce01c "$bundle" | sha256sum --check tar --no-same-owner -xzf "$bundle" -C "$RUNNER_TEMP" \ @@ -220,7 +220,7 @@ jobs: sudo apt-get install -y --fix-missing idris2 2>/dev/null || { IDRIS2_VER="v0.8.0" sudo apt-get install -y --fix-missing chezscheme make libgmp-dev - curl -fsSL --max-time 300 --retry 2 \ + curl --proto '=https' --proto-redir '=https' -fsSL --max-time 300 --retry 2 \ -o /tmp/idris2.tar.gz \ "https://github.com/idris-lang/Idris2/archive/refs/tags/${IDRIS2_VER}.tar.gz" mkdir -p /tmp/idris2-src @@ -231,7 +231,7 @@ jobs: lean4) # elan bootstraps the Lean toolchain manager and downloads lean4:stable. # --max-time guards the pipe against a stalled download. - curl -sSL --max-time 120 \ + curl --proto '=https' --proto-redir '=https' -sSL --max-time 120 \ https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh \ | sh -s -- -y --default-toolchain leanprover/lean4:stable echo "$HOME/.elan/bin" >> "$GITHUB_PATH" @@ -241,7 +241,7 @@ jobs: # --retry 3 + --max-time 600 guard the large download. # find locates the binary regardless of extracted dir name, # so the symlink survives a future Isabelle2025 release. - curl -fsSL --max-time 600 --retry 3 --retry-delay 15 \ + curl --proto '=https' --proto-redir '=https' -fsSL --max-time 600 --retry 3 --retry-delay 15 \ -o /tmp/isabelle.tar.gz \ https://isabelle.in.tum.de/dist/Isabelle2024_linux.tar.gz sudo mkdir -p /opt/isabelle @@ -268,7 +268,7 @@ jobs: # Update FSTAR_VER when a newer release lands. # find locates fstar.exe regardless of extracted directory layout. FSTAR_VER="v2026.04.17" - curl -fsSL --max-time 300 --retry 2 \ + curl --proto '=https' --proto-redir '=https' -fsSL --max-time 300 --retry 2 \ -o /tmp/fstar.tar.gz \ "https://github.com/FStarLang/FStar/releases/download/${FSTAR_VER}/fstar-${FSTAR_VER}-Linux-x86_64.tar.gz" sudo mkdir -p /opt/fstar @@ -288,7 +288,7 @@ jobs: # The installer installs to /usr/local/lib/tlaps and links # tlapm into /usr/local/bin — no --prefix needed or supported. # find is a safety net for any layout variation. - curl -fsSL --max-time 120 \ + curl --proto '=https' --proto-redir '=https' -fsSL --max-time 120 \ -o /tmp/tlaps.bin \ "https://github.com/tlaplus/tlapm/releases/download/202210041448/tlaps-1.5.0-x86_64-linux-gnu-inst.bin" chmod +x /tmp/tlaps.bin @@ -356,9 +356,9 @@ jobs: case "${{ matrix.backend }}" in tamarin) # Tamarin publishes prebuilt Linux x86_64 tarballs. - curl -fsSL -o /tmp/tamarin.tar.gz \ + curl --proto '=https' --proto-redir '=https' -fsSL -o /tmp/tamarin.tar.gz \ https://github.com/tamarin-prover/tamarin-prover/releases/latest/download/tamarin-prover-linux64-ubuntu.tar.gz || \ - curl -fsSL -o /tmp/tamarin.tar.gz \ + curl --proto '=https' --proto-redir '=https' -fsSL -o /tmp/tamarin.tar.gz \ https://github.com/tamarin-prover/tamarin-prover/releases/download/1.10.0/tamarin-prover-1.10.0-linux64-ubuntu.tar.gz sudo mkdir -p /opt/tamarin sudo tar xzf /tmp/tamarin.tar.gz -C /opt/tamarin @@ -389,7 +389,7 @@ jobs: # Google OR-Tools ships prebuilt Linux tarballs. The binary we # expose to echidna is a small wrapper that invokes the solver CLI. OR_URL="https://github.com/google/or-tools/releases/latest/download/or-tools_amd64_ubuntu-22.04_cpp_v9.11.4210.tar.gz" - curl -fsSL -o /tmp/ortools.tar.gz "$OR_URL" || { + curl --proto '=https' --proto-redir '=https' -fsSL -o /tmp/ortools.tar.gz "$OR_URL" || { echo "OR-Tools tarball unavailable at expected URL; test will SKIP." exit 0 } diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 8398bf6a..7b9dd461 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -23,4 +23,4 @@ jobs: # caller must grant at least that or the run startup-fails. permissions: contents: read - uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@8d94c37463ccdba24810da488364a7c3f82aea37 # published source, 2026-11-04 + uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@a12b2b4644db1a1250bc50b58fd24c99f89f564e # published source, 2026-11-04 diff --git a/.github/workflows/static-analysis-gate.yml b/.github/workflows/static-analysis-gate.yml index dfc93031..cfb6a8fe 100644 --- a/.github/workflows/static-analysis-gate.yml +++ b/.github/workflows/static-analysis-gate.yml @@ -14,8 +14,9 @@ concurrency: permissions: contents: read env: - # The vetted scanner pins from standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9. - HYPATIA_REF: adb2f41db34156cf81b2508f6596a10c7f076098 + # Exact Hypatia repair canary: Agda keyword boundaries and patched dependencies. + # The panic-attack pin remains the standards-vetted source pending coverage repair. + HYPATIA_REF: 82396d81677b11d17c7a0dede5bf0efcf768d5a1 PANIC_ATTACK_REF: 410a8f821ea8297833162565b50a298f787815da SCAN_EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} jobs: diff --git a/container/Containerfile b/container/Containerfile index ef8f4fa9..f7ab0f5f 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -12,7 +12,7 @@ # ============================================================================= # Stage 1: Builder # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS builder +FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS builder RUN apk add --no-cache \ rust \ @@ -32,7 +32,7 @@ RUN cargo build --release --bin echidna # ============================================================================= # Stage 2: Runtime # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 +FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 LABEL org.opencontainers.image.title="ECHIDNA" \ org.opencontainers.image.description="Extensible Cognitive Hybrid Intelligence for Deductive Neural Assistance" \ diff --git a/scripts/ci/install-cargo-audit.sh b/scripts/ci/install-cargo-audit.sh index 8eb33102..51cf576c 100644 --- a/scripts/ci/install-cargo-audit.sh +++ b/scripts/ci/install-cargo-audit.sh @@ -11,7 +11,7 @@ if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then fi tool_dir=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/echidna-cargo-audit.XXXXXX") archive="$tool_dir/cargo-audit.tgz" -curl --fail --location --retry 3 --output "$archive" \ +curl --proto '=https' --proto-redir '=https' --fail --location --retry 3 --output "$archive" \ https://github.com/rustsec/rustsec/releases/download/cargo-audit/v0.22.2/cargo-audit-x86_64-unknown-linux-musl-v0.22.2.tgz printf '%s %s\n' \ 7fb9497f8594b389e5fce5ef9b92db08432996895b2e0c5a0167a69ed445c428 \ diff --git a/scripts/ci/static-analysis-evidence.sh b/scripts/ci/static-analysis-evidence.sh index 905e8b12..41041c5c 100755 --- a/scripts/ci/static-analysis-evidence.sh +++ b/scripts/ci/static-analysis-evidence.sh @@ -9,14 +9,15 @@ root=${SCAN_ROOT:-$PWD} subject=$(git -C "$root" rev-parse HEAD) subject_tree=$(git -C "$root" rev-parse 'HEAD^{tree}') worktree_clean=false -if [ -z "$(git -C "$root" status --porcelain)" ]; then worktree_clean=true; fi +if [[ -z "$(git -C "$root" status --porcelain)" ]]; then worktree_clean=true; fi expected=${SCAN_EXPECTED_SHA:?the workflow must name the exact scanned commit} repository=${GITHUB_REPOSITORY:-local/fixture} run_id=${GITHUB_RUN_ID:-local} run_attempt=${GITHUB_RUN_ATTEMPT:-1} one_json() { - jq -e -s 'length == 1' "$1" >/dev/null 2>&1 + local report=$1 + jq -e -s 'length == 1' "$report" >/dev/null 2>&1 } valid_report() { @@ -50,7 +51,7 @@ valid_report() { counts() { local tool=$1 report=$2 - if [ "$tool" = patch-bridge ]; then + if [[ "$tool" = patch-bridge ]]; then jq -c '{mitigated,unmitigable,concatenative,informational,total:(.cves|length)}' "$report" else jq -c --arg tool "$tool" ' @@ -74,13 +75,13 @@ scan() { report_path=$null_report rm -f -- "$report" "$receipt" - if [ "$subject" != "$expected" ] || [ "$worktree_clean" != true ]; then + if [[ "$subject" != "$expected" ]] || [[ "$worktree_clean" != true ]]; then reason="checkout is dirty or does not match the requested scan commit" - elif [ -x "$binary" ] && [ -s "$source" ] && one_json "$source" && + elif [[ -x "$binary" ]] && [[ -s "$source" ]] && one_json "$source" && jq -e --arg ref "$ref" '.source_sha == $ref and (.binary_sha256 | test("^[0-9a-f]{64}$"))' "$source" >/dev/null; then digest=$(sha256sum "$binary" | cut -d' ' -f1) source_sha=$(jq -r '.source_sha' "$source") - if [ "$digest" != "$(jq -r '.binary_sha256' "$source")" ]; then + if [[ "$digest" != "$(jq -r '.binary_sha256' "$source")" ]]; then reason="scanner binary does not match its build receipt" else local -a argv @@ -92,19 +93,19 @@ scan() { esac command=$(jq -cn --args '$ARGS.positional' -- "${argv[@]}") rc=0 - if [ "$tool" = hypatia ]; then + if [[ "$tool" = hypatia ]]; then timeout 300 "${argv[@]}" > "$report" 2> "$log" || rc=$? else timeout 300 "${argv[@]}" > "$log" 2>&1 || rc=$? fi # Preserve the tool's own partial coverage even when its nonzero exit # prevents qualification. A partial report is evidence, not a clean scan. - if [ "$tool" = panic-attack ] && one_json "$report" && + if [[ "$tool" = panic-attack ]] && one_json "$report" && jq -e 'type == "object" and (.coverage | type == "object")' "$report" >/dev/null 2>&1; then jq -c '.coverage' "$report" > "$coverage_file" fi status=error - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then reason="scanner execution failed or timed out (exit $rc); raw output retained" elif ! valid_report "$tool" "$report"; then reason="scanner returned no complete report matching its pinned output contract" @@ -117,6 +118,7 @@ scan() { panic-attack) jq -c '.coverage' "$report" > "$coverage_file" ;; patch-bridge) jq -c '{mode:"online",total_dependencies}' "$report" > "$coverage_file" ;; hypatia) printf '%s\n' '{"mode":"hypatia-default-rules","severity_threshold":"medium","remote_alert_credentials":"not_supplied"}' > "$coverage_file" ;; + *) echo "Unknown scanner: $tool" >&2; return 2 ;; esac fi fi @@ -135,19 +137,19 @@ scan() { binary_sha256:(if $digest == "" then null else $digest end),command:$command}, evidence_kind:"scanner_observation"}' > "$receipt" rm -f -- "$null_report" - if [ -n "${GITHUB_OUTPUT:-}" ]; then + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then printf 'status=%s\n' "$status" >> "$GITHUB_OUTPUT" fi - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { printf '## %s\n\nStatus: **%s**\n\nSource: `%s`\n\nScanned commit: `%s`\n\n' "$tool" "$status" "$ref" "$subject" - if [ "$status" = ran ]; then jq -r '.counts | to_entries[] | "- \(.key): \(.value)"' "$receipt" + if [[ "$status" = ran ]]; then jq -r '.counts | to_entries[] | "- \(.key): \(.value)"' "$receipt" else printf '%s. This is not a clean scan.\n' "$reason"; fi } >> "$GITHUB_STEP_SUMMARY" fi - if [ "$status" != ran ]; then + if [[ "$status" != ran ]]; then printf '::error::%s: %s\n' "$tool" "$reason" >&2 - [ ! -f "$log" ] || cat "$log" >&2 + [[ ! -f "$log" ]] || cat "$log" >&2 return 2 fi } @@ -162,8 +164,8 @@ expected_ref() { qualified_receipt() { local tool=$1 ref=$2 receipt=$3 report=$4 expected_counts - [ "$subject" = "$expected" ] || return 1 - [ -s "$receipt" ] && one_json "$receipt" || return 1 + [[ "$subject" = "$expected" ]] || return 1 + [[ -s "$receipt" ]] && one_json "$receipt" || return 1 jq -e --arg tool "$tool" --arg ref "$ref" --arg sha "$expected" --arg tree "$subject_tree" \ --arg repo "$repository" --arg run "$run_id" --arg attempt "$run_attempt" ' .tool == $tool and .ref == $ref and .status == "ran" and .exit_code == 0 @@ -192,11 +194,11 @@ gate() { return 2 fi rm -f -- "$report" - if [ "$tool" = patch-bridge ]; then - if [ "$(jq -r .counts.unmitigable "$receipt")" -gt 0 ]; then + if [[ "$tool" = patch-bridge ]]; then + if [[ "$(jq -r .counts.unmitigable "$receipt")" -gt 0 ]]; then echo '::warning::Patch Bridge found unmitigable CVEs; architectural review is required.' fi - elif [ "$(jq -r .counts.critical "$receipt")" -gt 0 ]; then + elif [[ "$(jq -r .counts.critical "$receipt")" -gt 0 ]]; then echo '::error::Critical scanner findings block merge; the original findings remain in the artifact.' return 1 fi @@ -213,6 +215,7 @@ deposit() { panic-attack) name=panic-attack-findings; ref=${PANIC_ATTACK_REF:?} ;; hypatia) name=hypatia-findings; ref=${HYPATIA_REF:?} ;; patch-bridge) name=bridge-report; ref=${PANIC_ATTACK_REF:?} ;; + *) echo "Unknown scanner: $tool" >&2; return 2 ;; esac receipt="$dir/$name/receipt.json" if qualified_receipt "$tool" "$ref" "$receipt" "$temp/$tool-report.json"; then @@ -221,14 +224,14 @@ deposit() { fi complete=false local receipt_status=not_run - if [ -e "$receipt" ]; then receipt_status=error; fi + if [[ -e "$receipt" ]]; then receipt_status=error; fi jq -cn --arg tool "$tool" --arg status "$receipt_status" \ '{tool:$tool,status:$status,reason:"missing, failed, malformed or mismatched scan receipt"}' > "$temp/$tool-status.json" done jq -cs --argjson complete "$complete" \ '{complete:$complete,evidence_kind:"scanner_observation",scanners:.,fleet_ingestion:"not_confirmed",repair_verification:"not_performed"}' \ "$temp/panic-attack-status.json" "$temp/hypatia-status.json" "$temp/patch-bridge-status.json" > "$dir/deposit-status.json" - if [ "$complete" != true ]; then + if [[ "$complete" != true ]]; then echo '::error::Incomplete scanner evidence; no unified-findings artifact or clean result is produced.' >&2 return 2 fi diff --git a/scripts/ci/test-static-analysis-evidence.sh b/scripts/ci/test-static-analysis-evidence.sh index d9960f7d..e2accf98 100755 --- a/scripts/ci/test-static-analysis-evidence.sh +++ b/scripts/ci/test-static-analysis-evidence.sh @@ -25,11 +25,11 @@ cat > "$tmp/scanner" <<'MOCK' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$@" > "$MOCK_ARGS" -if [ "$1" = scan ]; then +if [[ "$1" = scan ]]; then cat "$MOCK_REPORT" else - while [ "$#" -gt 0 ]; do - if [ "$1" = --output ]; then cp "$MOCK_REPORT" "$2"; break; fi + while [[ "$#" -gt 0 ]]; do + if [[ "$1" = --output ]]; then cp "$MOCK_REPORT" "$2"; break; fi shift done fi @@ -45,7 +45,7 @@ expect_exit() { local wanted=$1 actual=0 shift "$@" > "$tmp/control.log" 2>&1 || actual=$? - if [ "$actual" -ne "$wanted" ]; then + if [[ "$actual" -ne "$wanted" ]]; then cat "$tmp/control.log" >&2 printf 'Expected exit %s, received %s: %s\n' "$wanted" "$actual" "$*" >&2 exit 1 @@ -69,6 +69,17 @@ printf '[]\n' > "$MOCK_REPORT" expect_exit 0 hypatia jq -e '.status == "ran" and .counts.total == 0' "$tmp/hypatia/receipt.json" >/dev/null expect_exit 0 bash "$adapter" gate "$tmp/hypatia/receipt.json" +# A failed-jobs-only rerun may reuse earlier successful jobs from this same +# run and exact source. Future/invalid attempts and another run are rejected. +jq '.provenance.run_attempt = "1"' "$tmp/hypatia/receipt.json" > "$tmp/prior-attempt.json" +expect_exit 0 bash "$adapter" gate "$tmp/prior-attempt.json" +for bad_attempt in 0 3 invalid; do + jq --arg attempt "$bad_attempt" '.provenance.run_attempt = $attempt' \ + "$tmp/hypatia/receipt.json" > "$tmp/bad-attempt.json" + expect_exit 2 bash "$adapter" gate "$tmp/bad-attempt.json" +done +jq '.provenance.run_id = "124"' "$tmp/prior-attempt.json" > "$tmp/other-run.json" +expect_exit 2 bash "$adapter" gate "$tmp/other-run.json" : > "$MOCK_REPORT" expect_exit 2 hypatia jq -e '.status == "error" and .counts == null and .report == null' "$tmp/hypatia/receipt.json" >/dev/null @@ -149,17 +160,17 @@ jq -e '.scan_completion.complete and .scan_completion.fleet_ingestion == "not_co and (.findings|length) == 2 and any(.findings[]; .severity == "critical")' "$tmp/findings/unified-findings.json" >/dev/null mv "$tmp/findings/bridge-report/receipt.json" "$tmp/bridge-backup.json" expect_exit 2 bash "$adapter" deposit "$tmp/findings" -test ! -e "$tmp/findings/unified-findings.json" +[[ ! -e "$tmp/findings/unified-findings.json" ]] jq -e '.complete == false' "$tmp/findings/deposit-status.json" >/dev/null cp "$tmp/bridge-backup.json" "$tmp/findings/bridge-report/receipt.json" jq '.provenance.commit_sha = "cccccccccccccccccccccccccccccccccccccccc"' "$tmp/qualified-hypatia.json" > "$tmp/findings/hypatia-findings/receipt.json" expect_exit 2 bash "$adapter" deposit "$tmp/findings" -test ! -e "$tmp/findings/unified-findings.json" +[[ ! -e "$tmp/findings/unified-findings.json" ]] cp "$tmp/qualified-hypatia.json" "$tmp/findings/hypatia-findings/receipt.json" cp "$tmp/missing-tool/receipt.json" "$tmp/findings/panic-attack-findings/receipt.json" expect_exit 2 bash "$adapter" deposit "$tmp/findings" -test ! -e "$tmp/findings/unified-findings.json" +[[ ! -e "$tmp/findings/unified-findings.json" ]] printf 'not-json\n' > "$tmp/findings/panic-attack-findings/receipt.json" expect_exit 2 bash "$adapter" deposit "$tmp/findings" -test ! -e "$tmp/findings/unified-findings.json" +[[ ! -e "$tmp/findings/unified-findings.json" ]] printf '%s receipt controls passed\n' "$passed" From 019cbc4fda78df2966c3e27c02e9f3a94b73d673 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:57:16 +0000 Subject: [PATCH 14/30] fix(config): restore valid prover and container metadata Merge duplicate Idris2 settings into one table, align the container entrypoint with its declared binary, and remove an empty unreferenced scan report. Preserve active scanner findings and their coverage requirements. --- .echidnabot.toml | 3 ++- reports/scan-results-echidna.json | 0 stapeln.toml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 reports/scan-results-echidna.json diff --git a/.echidnabot.toml b/.echidnabot.toml index 9df6ff54..dfb9973d 100644 --- a/.echidnabot.toml +++ b/.echidnabot.toml @@ -16,7 +16,6 @@ secondary_languages = ["julia", "affinescript", "idris2", "chapel", "spark"] [provers] # ECHIDNA is self-hosting - can verify its own Idris2 ABI definitions -idris2 = { enabled = true, priority = "high" } # Can also use SMT solvers for property checking z3 = { enabled = true, priority = "medium" } @@ -27,6 +26,8 @@ coq = { enabled = true, priority = "low" } lean = { enabled = true, priority = "low" } [provers.idris2] +enabled = true +priority = "high" package_file = "src/abi/echidnaabi.ipkg" source_dirs = ["src/abi"] totality_required = true diff --git a/reports/scan-results-echidna.json b/reports/scan-results-echidna.json deleted file mode 100644 index e69de29b..00000000 diff --git a/stapeln.toml b/stapeln.toml index 1cffbbfa..97aa58ec 100644 --- a/stapeln.toml +++ b/stapeln.toml @@ -53,7 +53,7 @@ packages = ["ca-certificates", "curl"] copy-from = [ { layer = "build", src = "/app/", dst = "/app/" }, ] -entrypoint = ["["/app/bin/echidna"]"] +entrypoint = ["/app/echidna"] user = "echidna" expose = [8081] env = { PATH = "/app/bin:/opt/elan/toolchains/stable/bin:${PATH}", ECHIDNA_PROVER_PATH = "/opt" } From b18688087725f3de8c13e32d4c770c5bf651955d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:23:16 +0000 Subject: [PATCH 15/30] fix(ci): bound container layers and verify Lean provisioning Remove Rust build intermediates in their build layer, retain the final binary, share a digest-verified pinned elan installer, and require both shipped solvers to start. Apply the unchanged official native actions.lock candidate from run41902500251 for source019cbc4. Local syntax and digest verification passed; hosted runtime qualification remains required. --- .github/workflows/actions.lock | 57 +++++++++++++++++++++--------- .github/workflows/container-ci.yml | 9 ++--- .github/workflows/live-provers.yml | 31 ++++++++-------- Containerfile | 32 +++++++++-------- scripts/ci/install-lean.sh | 25 +++++++++++++ 5 files changed, 103 insertions(+), 51 deletions(-) create mode 100644 scripts/ci/install-lean.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 7754a808..8ffbb352 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -6,7 +6,6 @@ workflows: '.github/workflows/agda-meta-checker.yml': - 'actions/cache@v6.1.0' - 'actions/checkout@v7.0.1' - - 'haskell-actions/setup@v2.12.1' '.github/workflows/boj-build.yml': - 'actions/checkout@v7.0.1' '.github/workflows/bridge-gate.yml': @@ -46,9 +45,9 @@ workflows: - 'actions/attest-build-provenance@v4.2.2' - 'actions/checkout@v7.0.1' '.github/workflows/governance.yml': - - 'hyperpolymath/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/hypatia-scan.yml': - - 'hyperpolymath/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' + - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/idris2-abi-ci.yml': - 'actions/checkout@v7.0.1' '.github/workflows/label-triage.yml': [] @@ -81,9 +80,9 @@ workflows: - 'swatinem/rust-cache@v2.9.2' - 'taiki-e/install-action@v2.87.28' '.github/workflows/scorecard.yml': - - 'hyperpolymath/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' + - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/secret-scanner.yml': - - 'hyperpolymath/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' + - 'hyperpolymath/cicd-suite@a12b2b4644db1a1250bc50b58fd24c99f89f564e' '.github/workflows/security-scan.yml': - 'hyperpolymath/panic-attack@8955f615f769204e42340e08bdea2de9d72e81cd' '.github/workflows/server-boot-gate.yml': @@ -91,7 +90,7 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.2' '.github/workflows/spark-theatre-gate.yml': - - 'hyperpolymath/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' + - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/static-analysis-gate.yml': - 'actions/checkout@v7.0.1' - 'actions/download-artifact@v8.0.2' @@ -100,7 +99,7 @@ workflows: '.github/workflows/verification-proofs-cron.yml': - 'actions/checkout@v7.0.1' '.github/workflows/workflow-linter.yml': - - 'actions/checkout@v7.0.1' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/zig-ffi-ci.yml': - 'actions/checkout@v7.0.1' - 'hyperpolymath/cicd-suite@4c772eb235b04455307d5c03cbbc4f45130f7f0f' @@ -148,6 +147,11 @@ dependencies: commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 repo_id: 438112499 + 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333': + ref: 'v8.0.2' + commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' + owner_id: 44036562 + repo_id: 192626254 'actions/download-artifact@v8.0.2': ref: 'v8.0.2' commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' @@ -225,16 +229,18 @@ dependencies: commit: 'sha1-884713a6c30a92e5e8544c39945cd7cb630abcd1' owner_id: 1342004 repo_id: 400046858 - 'haskell-actions/setup@v2.12.1': - ref: 'v2.12.1' - commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' - owner_id: 75048950 - repo_id: 623796603 'hyperpolymath/cicd-suite@4c772eb235b04455307d5c03cbbc4f45130f7f0f': ref: 'main' commit: 'sha1-4c772eb235b04455307d5c03cbbc4f45130f7f0f' owner_id: 6759885 repo_id: 1326697643 + 'hyperpolymath/cicd-suite@a12b2b4644db1a1250bc50b58fd24c99f89f564e': + ref: 'a12b2b4644db1a1250bc50b58fd24c99f89f564e' + commit: 'sha1-a12b2b4644db1a1250bc50b58fd24c99f89f564e' + owner_id: 6759885 + repo_id: 1326697643 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' 'hyperpolymath/panic-attack@8955f615f769204e42340e08bdea2de9d72e81cd': ref: '8955f615f769204e42340e08bdea2de9d72e81cd' commit: 'sha1-8955f615f769204e42340e08bdea2de9d72e81cd' @@ -247,20 +253,37 @@ dependencies: 'hyperpolymath/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0': ref: '3c00687a5bbd0d881d351db8502417b5d0a8b0b0' commit: 'sha1-3c00687a5bbd0d881d351db8502417b5d0a8b0b0' - owner_id: 6759885 + owner_id: 297579217 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12': + ref: 'a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' + commit: 'sha1-a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' + owner_id: 297579217 repo_id: 1116521501 uses: - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393' - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' + 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9': + ref: 'd3e4b485a2af90c92a8e18af0888cd19cb132cd9' + commit: 'sha1-d3e4b485a2af90c92a8e18af0888cd19cb132cd9' + owner_id: 297579217 + repo_id: 1116521501 + uses: + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' - - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' - - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' 'mlugg/setup-zig@v2.2.1': ref: 'v2.2.1' commit: 'sha1-d1434d08867e3ee9daa34448df10607b98908d29' diff --git a/.github/workflows/container-ci.yml b/.github/workflows/container-ci.yml index 3e435a41..8cc44c81 100644 --- a/.github/workflows/container-ci.yml +++ b/.github/workflows/container-ci.yml @@ -23,6 +23,7 @@ on: branches: [main, master] paths: - 'Containerfile' + - 'scripts/ci/install-lean.sh' - '.containerization/**' - 'Cargo.toml' - 'Cargo.lock' @@ -31,6 +32,7 @@ on: pull_request: paths: - 'Containerfile' + - 'scripts/ci/install-lean.sh' - '.containerization/**' - 'Cargo.toml' - 'Cargo.lock' @@ -94,10 +96,9 @@ jobs: - name: Verify solver availability inside container run: | echo "=== Checking installed solvers ===" - podman run --rm --entrypoint /bin/sh echidna:ci-test -c ' - echo "Z3:"; z3 --version 2>/dev/null || echo " not found" - echo "Lean:"; lean --version 2>/dev/null || echo " not found" - echo "Idris2:"; idris2 --version 2>/dev/null || echo " not found" + podman run --rm --entrypoint /bin/sh echidna:ci-test -ec ' + echo "Z3:"; z3 --version + echo "Lean:"; lean --version ' - name: Clean up diff --git a/.github/workflows/live-provers.yml b/.github/workflows/live-provers.yml index 8648ed04..7bfc37c9 100644 --- a/.github/workflows/live-provers.yml +++ b/.github/workflows/live-provers.yml @@ -89,20 +89,21 @@ jobs: - name: Provision prover (${{ matrix.backend }}) run: | set -euo pipefail + readonly https_protocol='=https' # Retry apt-get update once on mirror failure before proceeding. sudo apt-get update -qq || sudo apt-get update -qq --fix-missing case "${{ matrix.backend }}" in z3) sudo apt-get install -y --fix-missing z3 ;; cvc5) # Since cvc5 1.2.0 releases ship as a zip with the binary at # bin/cvc5; the old bare `cvc5-Linux-static` asset no longer exists. - curl --proto '=https' --proto-redir '=https' -sSL --max-time 120 -o /tmp/cvc5.zip \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -sSL --max-time 120 -o /tmp/cvc5.zip \ https://github.com/cvc5/cvc5/releases/download/cvc5-1.3.3/cvc5-Linux-x86_64-static.zip unzip -j -o /tmp/cvc5.zip 'cvc5-Linux-x86_64-static/bin/cvc5' -d /tmp sudo install -m 0755 /tmp/cvc5 /usr/local/bin/cvc5 ;; vampire) # Asset was renamed from vampire-Linux-x86_64.zip to # vampire-Linux-X64.zip starting with v5.x; zip contains # a single file named `vampire` at the root. - curl --proto '=https' --proto-redir '=https' -sSL --max-time 120 -o /tmp/vampire.zip \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -sSL --max-time 120 -o /tmp/vampire.zip \ https://github.com/vprover/vampire/releases/download/v5.0.1/vampire-Linux-X64.zip unzip -j -o /tmp/vampire.zip -d /tmp sudo install -m 0755 /tmp/vampire /usr/local/bin/vampire ;; @@ -111,7 +112,7 @@ jobs: alt-ergo) # alt-ergo is not in the Ubuntu 24.04 (noble) apt repos. # Install the prebuilt musl static binary from OCamlPro's # GitHub releases instead. - curl --proto '=https' --proto-redir '=https' -sSL --max-time 120 -o /tmp/alt-ergo \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -sSL --max-time 120 -o /tmp/alt-ergo \ https://github.com/OCamlPro/alt-ergo/releases/download/v2.6.3/alt-ergo-v2.6.3-x86_64-linux-musl sudo install -m 0755 /tmp/alt-ergo /usr/local/bin/alt-ergo ;; glpk) sudo apt-get install -y --fix-missing glpk-utils ;; @@ -119,7 +120,7 @@ jobs: chuffed) # Ubuntu's minizinc package does not include fzn-chuffed. # The upstream bundle does; authenticate it before extraction. bundle="$RUNNER_TEMP/MiniZincIDE-2.10.1-x86_64-linux-gnu.tgz" - curl --proto '=https' --proto-redir '=https' --fail --location --retry 3 --max-time 120 --output "$bundle" \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" --fail --location --retry 3 --max-time 120 --output "$bundle" \ https://github.com/MiniZinc/MiniZincIDE/releases/download/2.10.1/MiniZincIDE-2.10.1-x86_64-linux-gnu.tgz printf '%s %s\n' b63dd88491d47b05d0eb0ff4aa1112d1e6f77292c023800b20b98851458ce01c "$bundle" | sha256sum --check tar --no-same-owner -xzf "$bundle" -C "$RUNNER_TEMP" \ @@ -202,6 +203,7 @@ jobs: continue-on-error: true # allow-fail: issue=#416 expires=2026-11-30 run: | set -euo pipefail + readonly https_protocol='=https' sudo apt-get update -qq || sudo apt-get update -qq --fix-missing case "${{ matrix.backend }}" in coq) @@ -220,7 +222,7 @@ jobs: sudo apt-get install -y --fix-missing idris2 2>/dev/null || { IDRIS2_VER="v0.8.0" sudo apt-get install -y --fix-missing chezscheme make libgmp-dev - curl --proto '=https' --proto-redir '=https' -fsSL --max-time 300 --retry 2 \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -fsSL --max-time 300 --retry 2 \ -o /tmp/idris2.tar.gz \ "https://github.com/idris-lang/Idris2/archive/refs/tags/${IDRIS2_VER}.tar.gz" mkdir -p /tmp/idris2-src @@ -229,11 +231,7 @@ jobs: } ;; lean4) - # elan bootstraps the Lean toolchain manager and downloads lean4:stable. - # --max-time guards the pipe against a stalled download. - curl --proto '=https' --proto-redir '=https' -sSL --max-time 120 \ - https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh \ - | sh -s -- -y --default-toolchain leanprover/lean4:stable + bash scripts/ci/install-lean.sh echo "$HOME/.elan/bin" >> "$GITHUB_PATH" ;; isabelle) @@ -241,7 +239,7 @@ jobs: # --retry 3 + --max-time 600 guard the large download. # find locates the binary regardless of extracted dir name, # so the symlink survives a future Isabelle2025 release. - curl --proto '=https' --proto-redir '=https' -fsSL --max-time 600 --retry 3 --retry-delay 15 \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -fsSL --max-time 600 --retry 3 --retry-delay 15 \ -o /tmp/isabelle.tar.gz \ https://isabelle.in.tum.de/dist/Isabelle2024_linux.tar.gz sudo mkdir -p /opt/isabelle @@ -268,7 +266,7 @@ jobs: # Update FSTAR_VER when a newer release lands. # find locates fstar.exe regardless of extracted directory layout. FSTAR_VER="v2026.04.17" - curl --proto '=https' --proto-redir '=https' -fsSL --max-time 300 --retry 2 \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -fsSL --max-time 300 --retry 2 \ -o /tmp/fstar.tar.gz \ "https://github.com/FStarLang/FStar/releases/download/${FSTAR_VER}/fstar-${FSTAR_VER}-Linux-x86_64.tar.gz" sudo mkdir -p /opt/fstar @@ -288,7 +286,7 @@ jobs: # The installer installs to /usr/local/lib/tlaps and links # tlapm into /usr/local/bin — no --prefix needed or supported. # find is a safety net for any layout variation. - curl --proto '=https' --proto-redir '=https' -fsSL --max-time 120 \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -fsSL --max-time 120 \ -o /tmp/tlaps.bin \ "https://github.com/tlaplus/tlapm/releases/download/202210041448/tlaps-1.5.0-x86_64-linux-gnu-inst.bin" chmod +x /tmp/tlaps.bin @@ -352,13 +350,14 @@ jobs: continue-on-error: true # allow-fail: issue=#416 expires=2026-11-30 run: | set -euo pipefail + readonly https_protocol='=https' sudo apt-get update -qq || sudo apt-get update -qq --fix-missing case "${{ matrix.backend }}" in tamarin) # Tamarin publishes prebuilt Linux x86_64 tarballs. - curl --proto '=https' --proto-redir '=https' -fsSL -o /tmp/tamarin.tar.gz \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -fsSL -o /tmp/tamarin.tar.gz \ https://github.com/tamarin-prover/tamarin-prover/releases/latest/download/tamarin-prover-linux64-ubuntu.tar.gz || \ - curl --proto '=https' --proto-redir '=https' -fsSL -o /tmp/tamarin.tar.gz \ + curl --proto "$https_protocol" --proto-redir "$https_protocol" -fsSL -o /tmp/tamarin.tar.gz \ https://github.com/tamarin-prover/tamarin-prover/releases/download/1.10.0/tamarin-prover-1.10.0-linux64-ubuntu.tar.gz sudo mkdir -p /opt/tamarin sudo tar xzf /tmp/tamarin.tar.gz -C /opt/tamarin @@ -389,7 +388,7 @@ jobs: # Google OR-Tools ships prebuilt Linux tarballs. The binary we # expose to echidna is a small wrapper that invokes the solver CLI. OR_URL="https://github.com/google/or-tools/releases/latest/download/or-tools_amd64_ubuntu-22.04_cpp_v9.11.4210.tar.gz" - curl --proto '=https' --proto-redir '=https' -fsSL -o /tmp/ortools.tar.gz "$OR_URL" || { + curl --proto "$https_protocol" --proto-redir "$https_protocol" -fsSL -o /tmp/ortools.tar.gz "$OR_URL" || { echo "OR-Tools tarball unavailable at expected URL; test will SKIP." exit 0 } diff --git a/Containerfile b/Containerfile index ea6e3742..38b4dbe0 100644 --- a/Containerfile +++ b/Containerfile @@ -13,7 +13,7 @@ # ============================================================================= # Stage 1: Rust Builder # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS rust-builder +FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS rust-builder RUN apk add --no-cache \ rust \ @@ -40,12 +40,16 @@ COPY crates ./crates # auto-discovered and tolerate absence, so they are intentionally not copied.) COPY benches ./benches -RUN cargo build --release --bin echidna +# Keep the output binary, then remove compilation intermediates in this same +# layer. Committing the full target tree exhausts small hosted build runners. +RUN cargo build --locked --release --bin echidna && \ + install -D -m 0755 target/release/echidna /out/echidna && \ + cargo clean # ============================================================================= # Stage 2: Prover Installer (Z3 from official release, Lean via elan) # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS prover-installer +FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS prover-installer RUN apk add --no-cache \ curl \ @@ -57,24 +61,23 @@ RUN apk add --no-cache \ WORKDIR /tmp # Install Z3 from official GitHub release -RUN curl -fsSL "https://github.com/Z3Prover/z3/releases/download/z3-4.13.4/z3-4.13.4-x64-glibc-2.35.zip" \ - -o z3.zip && \ +RUN curl --proto '=https' --proto-redir '=https' -fsSL --connect-timeout 20 --max-time 180 --retry 3 \ + "https://github.com/Z3Prover/z3/releases/download/z3-4.13.4/z3-4.13.4-x64-glibc-2.35.zip" -o z3.zip && \ + echo '93f91f9c6f4a00a041c19fc7a74adc1f441c8244ce70d486e19abcd89c6a014b z3.zip' | sha256sum -c - && \ unzip -q z3.zip && \ mkdir -p /opt/z3/bin && \ cp z3-4.13.4-x64-glibc-2.35/bin/z3 /opt/z3/bin/ && \ chmod +x /opt/z3/bin/z3 && \ rm -rf z3.zip z3-4.13.4-x64-glibc-2.35 -# Install Lean 4 via elan -RUN curl -fsSL https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh | \ - bash -s -- -y --default-toolchain stable && \ - . "$HOME/.elan/env" && \ - lean --version +# Use the same verified installer and pinned Lean version as the live gate. +COPY scripts/ci/install-lean.sh /tmp/install-lean.sh +RUN ELAN_HOME=/opt/elan bash /tmp/install-lean.sh # ============================================================================= # Stage 3: Runtime Image (chainguard) # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 +FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 LABEL maintainer="Jonathan D.A. Jewell " LABEL org.opencontainers.image.source="https://github.com/hyperpolymath/echidna" @@ -91,11 +94,12 @@ RUN apk add --no-cache \ WORKDIR /app -COPY --from=rust-builder /build/target/release/echidna /app/bin/echidna +COPY --from=rust-builder /out/echidna /app/bin/echidna COPY --from=prover-installer /opt/z3/bin/z3 /app/bin/z3 -COPY --from=prover-installer /root/.elan /opt/elan +COPY --from=prover-installer /opt/elan /opt/elan -ENV PATH="/app/bin:/opt/elan/toolchains/stable/bin:${PATH}" +ENV ELAN_HOME="/opt/elan" +ENV PATH="/app/bin:/opt/elan/bin:${PATH}" ENV ECHIDNA_PROVER_PATH="/opt" RUN mkdir -p /app/proofs /app/config /app/logs diff --git a/scripts/ci/install-lean.sh b/scripts/ci/install-lean.sh new file mode 100644 index 00000000..21b88722 --- /dev/null +++ b/scripts/ci/install-lean.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Shared, digest-verified installer for the repository's pinned Lean toolchain. +set -euo pipefail +[[ $# == 0 ]] || { echo 'usage: install-lean.sh' >&2; exit 2; } +[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]] || { + echo 'The qualified elan asset is for x86_64 Linux only' >&2; exit 2; +} +readonly elan_version=v4.2.4 +readonly elan_sha256=42b94d4244e8353142c456ec0e4ca6528fd898a6c604d4059f494e706e431f63 +readonly lean_toolchain=leanprover/lean4:v4.13.0 +readonly https_protocol='=https' +installer_dir=$(mktemp -d) +trap 'rm -rf -- "$installer_dir"' EXIT +curl --fail --silent --show-error --location \ + --proto "$https_protocol" --proto-redir "$https_protocol" \ + --connect-timeout 20 --max-time 120 --retry 3 --retry-max-time 300 \ + -o "$installer_dir/elan.tar.gz" \ + "https://github.com/leanprover/elan/releases/download/${elan_version}/elan-x86_64-unknown-linux-gnu.tar.gz" +printf '%s %s\n' "$elan_sha256" "$installer_dir/elan.tar.gz" | sha256sum --check +tar --no-same-owner -xzf "$installer_dir/elan.tar.gz" -C "$installer_dir" +export ELAN_HOME="${ELAN_HOME:-$HOME/.elan}" +"$installer_dir/elan-init" -y --no-modify-path --default-toolchain "$lean_toolchain" +"$ELAN_HOME/bin/lean" --version From b8dc7fa90f82ab124b6af18398999ef6a9724e32 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:46:02 +0000 Subject: [PATCH 16/30] fix(ci): use canonical workflow owners and portable checksum verification Keep exact reviewed workflow SHAs while moving the transferred standards mirror caller and canary scanner to their canonical repositories. Use sha256sum -c - so the mandatory elan checksum check works in BusyBox containers. The previous official lock exposes the transferred owner's identity mismatch; regeneration for this new source is still required and no native lock metadata is edited by hand. --- .github/workflows/mirror.yml | 2 +- .github/workflows/secret-scanner.yml | 2 +- scripts/ci/install-lean.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index a623e1ba..c2a07042 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -24,7 +24,7 @@ permissions: jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@3c00687a5bbd0d881d351db8502417b5d0a8b0b0 # main 2026-10-20, standards#1315 hypatia-ref aa34a4cc (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@3c00687a5bbd0d881d351db8502417b5d0a8b0b0 # main 2026-10-20, standards#1315 hypatia-ref aa34a4cc (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) secrets: GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 7b9dd461..78c541f0 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -23,4 +23,4 @@ jobs: # caller must grant at least that or the run startup-fails. permissions: contents: read - uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@a12b2b4644db1a1250bc50b58fd24c99f89f564e # published source, 2026-11-04 + uses: hyperpolymath/proven-cicd/.github/workflows/secret-scanner-reusable.yml@a12b2b4644db1a1250bc50b58fd24c99f89f564e # published source, 2026-11-04 diff --git a/scripts/ci/install-lean.sh b/scripts/ci/install-lean.sh index 21b88722..109c9940 100644 --- a/scripts/ci/install-lean.sh +++ b/scripts/ci/install-lean.sh @@ -18,7 +18,7 @@ curl --fail --silent --show-error --location \ --connect-timeout 20 --max-time 120 --retry 3 --retry-max-time 300 \ -o "$installer_dir/elan.tar.gz" \ "https://github.com/leanprover/elan/releases/download/${elan_version}/elan-x86_64-unknown-linux-gnu.tar.gz" -printf '%s %s\n' "$elan_sha256" "$installer_dir/elan.tar.gz" | sha256sum --check +printf '%s %s\n' "$elan_sha256" "$installer_dir/elan.tar.gz" | sha256sum -c - tar --no-same-owner -xzf "$installer_dir/elan.tar.gz" -C "$installer_dir" export ELAN_HOME="${ELAN_HOME:-$HOME/.elan}" "$installer_dir/elan-init" -y --no-modify-path --default-toolchain "$lean_toolchain" From d11ba4695a7aa75d97032b828ee87f6fa16d3246 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:02:14 +0000 Subject: [PATCH 17/30] fix(container): add explicit latest tag to digest-pinned Wolfi base images --- Containerfile | 6 +++--- container/Containerfile | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Containerfile b/Containerfile index 38b4dbe0..035c04db 100644 --- a/Containerfile +++ b/Containerfile @@ -13,7 +13,7 @@ # ============================================================================= # Stage 1: Rust Builder # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS rust-builder +FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS rust-builder RUN apk add --no-cache \ rust \ @@ -49,7 +49,7 @@ RUN cargo build --locked --release --bin echidna && \ # ============================================================================= # Stage 2: Prover Installer (Z3 from official release, Lean via elan) # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS prover-installer +FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS prover-installer RUN apk add --no-cache \ curl \ @@ -77,7 +77,7 @@ RUN ELAN_HOME=/opt/elan bash /tmp/install-lean.sh # ============================================================================= # Stage 3: Runtime Image (chainguard) # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 +FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 LABEL maintainer="Jonathan D.A. Jewell " LABEL org.opencontainers.image.source="https://github.com/hyperpolymath/echidna" diff --git a/container/Containerfile b/container/Containerfile index f7ab0f5f..ef8f4fa9 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -12,7 +12,7 @@ # ============================================================================= # Stage 1: Builder # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS builder +FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 AS builder RUN apk add --no-cache \ rust \ @@ -32,7 +32,7 @@ RUN cargo build --release --bin echidna # ============================================================================= # Stage 2: Runtime # ============================================================================= -FROM cgr.dev/chainguard/wolfi-base@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 +FROM cgr.dev/chainguard/wolfi-base:latest@sha256:9c2092b053779e14c82fb50f77b37bcc38b7d2c83972352d5813280f9d035b03 LABEL org.opencontainers.image.title="ECHIDNA" \ org.opencontainers.image.description="Extensible Cognitive Hybrid Intelligence for Deductive Neural Assistance" \ From 87c557a10994f49305b964ce4d879bace80e4f93 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:22:38 +0000 Subject: [PATCH 18/30] fix: close probe, workflow and documentation review defects Reject failed GPU version probes, retain hostname IPC support, and add process and address regressions. Drain Agda path matching, support scanner artifact retries, and correct active routing and assurance documentation. Apply unchanged official native lock output from run41912284629, job126862704009, generated for d11ba4695a7aa75d97032b828ee87f6fa16d3246. The generated lock blob is411417cad0b17d4cd672e2840583f48b870cb810; workflow action references are unchanged by this follow-up. Local runtime is unavailable. New Rust regressions and the combined source require hosted validation; no local execution result is claimed. --- .github/workflows/actions.lock | 20 +-- .github/workflows/agda-meta-checker.yml | 2 +- .github/workflows/static-analysis-gate.yml | 6 + .github/workflows/workflow-linter.yml | 2 +- 0-AI-MANIFEST.a2ml | 2 +- docs/HTTP-CLIENT-COMPATIBILITY.adoc | 2 +- docs/SSG_ROADMAP.adoc | 2 +- ...6-11-01-routing-and-rule-engine-recon.adoc | 20 ++- src/interfaces/README.adoc | 6 +- src/rust/ipc/mod.rs | 34 +++- src/rust/provers/faial.rs | 152 ++++++++++++++++-- src/rust/provers/gpuverify.rs | 152 ++++++++++++++++-- 12 files changed, 344 insertions(+), 56 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 8ffbb352..411417ca 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -57,7 +57,7 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.2' '.github/workflows/mirror.yml': - - 'hyperpolymath/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' + - 'metadatastician/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' '.github/workflows/mvp-smoke.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' @@ -82,7 +82,7 @@ workflows: '.github/workflows/scorecard.yml': - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/secret-scanner.yml': - - 'hyperpolymath/cicd-suite@a12b2b4644db1a1250bc50b58fd24c99f89f564e' + - 'hyperpolymath/proven-cicd@a12b2b4644db1a1250bc50b58fd24c99f89f564e' '.github/workflows/security-scan.yml': - 'hyperpolymath/panic-attack@8955f615f769204e42340e08bdea2de9d72e81cd' '.github/workflows/server-boot-gate.yml': @@ -234,13 +234,6 @@ dependencies: commit: 'sha1-4c772eb235b04455307d5c03cbbc4f45130f7f0f' owner_id: 6759885 repo_id: 1326697643 - 'hyperpolymath/cicd-suite@a12b2b4644db1a1250bc50b58fd24c99f89f564e': - ref: 'a12b2b4644db1a1250bc50b58fd24c99f89f564e' - commit: 'sha1-a12b2b4644db1a1250bc50b58fd24c99f89f564e' - owner_id: 6759885 - repo_id: 1326697643 - uses: - - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' 'hyperpolymath/panic-attack@8955f615f769204e42340e08bdea2de9d72e81cd': ref: '8955f615f769204e42340e08bdea2de9d72e81cd' commit: 'sha1-8955f615f769204e42340e08bdea2de9d72e81cd' @@ -250,7 +243,14 @@ dependencies: - 'Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - 'hyperpolymath/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0': + 'hyperpolymath/proven-cicd@a12b2b4644db1a1250bc50b58fd24c99f89f564e': + ref: 'a12b2b4644db1a1250bc50b58fd24c99f89f564e' + commit: 'sha1-a12b2b4644db1a1250bc50b58fd24c99f89f564e' + owner_id: 6759885 + repo_id: 1446155688 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + 'metadatastician/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0': ref: '3c00687a5bbd0d881d351db8502417b5d0a8b0b0' commit: 'sha1-3c00687a5bbd0d881d351db8502417b5d0a8b0b0' owner_id: 297579217 diff --git a/.github/workflows/agda-meta-checker.yml b/.github/workflows/agda-meta-checker.yml index d5510daa..301110dc 100644 --- a/.github/workflows/agda-meta-checker.yml +++ b/.github/workflows/agda-meta-checker.yml @@ -59,7 +59,7 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then FILES=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ --paginate --jq '.[].filename') - if printf '%s\n' "$FILES" | grep -qE "$PATTERN"; then + if printf '%s\n' "$FILES" | grep -E "$PATTERN" > /dev/null; then echo "relevant=true" >> "$GITHUB_OUTPUT" echo "meta-checker/ sources changed — running full Agda type-check." else diff --git a/.github/workflows/static-analysis-gate.yml b/.github/workflows/static-analysis-gate.yml index cfb6a8fe..a33b0240 100644 --- a/.github/workflows/static-analysis-gate.yml +++ b/.github/workflows/static-analysis-gate.yml @@ -53,6 +53,7 @@ jobs: - name: Preserve the pinned scanner for Patch Bridge uses: actions/upload-artifact@v7.0.2 with: + overwrite: true name: panic-attack-toolchain path: | ${{ runner.temp }}/panic-toolchain/bin/panic-attack @@ -69,6 +70,7 @@ jobs: if: always() uses: actions/upload-artifact@v7.0.2 with: + overwrite: true name: panic-attack-findings path: ${{ runner.temp }}/panic-attack-findings/ if-no-files-found: error @@ -119,6 +121,7 @@ jobs: if: always() uses: actions/upload-artifact@v7.0.2 with: + overwrite: true name: hypatia-findings path: ${{ runner.temp }}/hypatia-findings/ if-no-files-found: error @@ -159,6 +162,7 @@ jobs: if: always() uses: actions/upload-artifact@v7.0.2 with: + overwrite: true name: bridge-report path: ${{ runner.temp }}/bridge-report/ if-no-files-found: error @@ -206,6 +210,7 @@ jobs: if: always() uses: actions/upload-artifact@v7.0.2 with: + overwrite: true name: static-analysis-evidence path: findings/ if-no-files-found: error @@ -214,6 +219,7 @@ jobs: if: steps.combine.outcome == 'success' uses: actions/upload-artifact@v7.0.2 with: + overwrite: true name: unified-findings path: findings/unified-findings.json if-no-files-found: error diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 68fc19ad..12080ac9 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -102,7 +102,7 @@ jobs: patch="$RUNNER_TEMP/actions-lock-refresh.patch" git -C "$subject" diff --binary -- .github/workflows/actions.lock > "$patch" if [ "$(wc -c < "$patch")" -gt 900000 ]; then - echo "::error::Candidate exceeds the summary budget; lock refresh needs artifact retrieval" + echo "::error::Candidate exceeds the summary budget; this workflow cannot publish it" exit 2 fi { diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index e280868c..e982842a 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -52,7 +52,7 @@ secondary = "Julia (ML inference, port 8090), AffineScript+Bun (UI, TEA architec optional = "Chapel (parallel proof dispatch)" abi = "Idris2 (7 modules, zero believe-me)" ffi = "Zig (4 shared libraries)" -interfaces = "GraphQL (8081), gRPC (50051), REST (8000)" +interfaces = "GraphQL (8082), gRPC (50051), REST (8000)" build = "Justfile (primary), Cargo workspace" container = "Podman + Containerfile" diff --git a/docs/HTTP-CLIENT-COMPATIBILITY.adoc b/docs/HTTP-CLIENT-COMPATIBILITY.adoc index 90fb2183..dadd925f 100644 --- a/docs/HTTP-CLIENT-COMPATIBILITY.adoc +++ b/docs/HTTP-CLIENT-COMPATIBILITY.adoc @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-License-Identifier: CC-BY-SA-4.0 = Fallible HTTP client construction HTTP transport construction returns errors through the Julia coprocessor bridge, diff --git a/docs/SSG_ROADMAP.adoc b/docs/SSG_ROADMAP.adoc index 8a7abaf1..c7f95b5b 100644 --- a/docs/SSG_ROADMAP.adoc +++ b/docs/SSG_ROADMAP.adoc @@ -117,4 +117,4 @@ Earlier versions of this roadmap remain available in version control at the source revision named above; they are historical proposals. * link:../.github/CONTRIBUTING.md[Contribution guidelines]. -* link:reports/2026-11-04-security-alert-triage.adoc[Source review and alert disposition]. +* https://github.com/hyperpolymath/echidna/pull/455[Current repair review and unresolved alert work]. diff --git a/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc b/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc index df31a5ed..e4655fcc 100644 --- a/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc +++ b/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc @@ -3,7 +3,8 @@ = Re-recon: routing surfaces and the rule-engine source -*Status*: current. Supersedes nothing; this is the first pass that classifies +*Status*: historical source review with follow-ups recorded on 2026-11-04. +Supersedes nothing; this is the first pass that classifies *every* client-facing path in the tree rather than only the ones a given consumer happens to use. *Date*: 2026-11-01. *Method*: static read of the checkout at `916b769` — no build, see <>. @@ -145,11 +146,16 @@ some other system publishes it. === What is therefore *not* claimed -* No claim that any proof in this repository is `+proven+`. See - link:../PROOF-STATUS.adoc[`+docs/PROOF-STATUS.adoc+`], where every obligation - row is `+open+` or `+argued+`, including the Agda meta-checker. -* No Grade 2/1 claim anywhere. There is no translation leg and no receipt - pinned to a commit hash, which Mathesis §6 requires. +* The 2026-11-04 follow-up in + link:../PROOF-STATUS.adoc[`+docs/PROOF-STATUS.adoc+`] records Agda rows R4, + R9, R10 and R11 as `+proven+` for the sources identified in + link:2026-11-04-agda-meta-checker-receipt.json[the Agda receipt]. + That receipt identifies a base commit plus exact checked file digests; + it does not certify an arbitrary later checkout or the operational Rust. + Other obligation statuses remain governed by their individual ledger rows. +* No Grade 2/1 claim anywhere. The recorded Agda home-system result remains + Grade 3: no independent translation and statement-correspondence leg has + been discharged. A source-bound local receipt does not supply that leg. * No percentage. Where a number exists in this codebase (`+verification/confidence.rs+`), it ranks backends against each other and is recorded as `+tested-only+` for internal ranking, never as assurance. @@ -543,7 +549,7 @@ scaffolding. |Older machine dialect (`+{"level":…}+`), still used by MCP. Superseded by the prove contract; no new consumer should adopt it. -|`+/api/prover+`, `+/api/verify+` response shapes +|`+/api/provers+`, `+/api/verify+` response shapes |compatibility-only |Untagged JSON; no schema id, no trust block. Kept for existing clients. diff --git a/src/interfaces/README.adoc b/src/interfaces/README.adoc index b7f185e8..936d744e 100644 --- a/src/interfaces/README.adoc +++ b/src/interfaces/README.adoc @@ -8,8 +8,8 @@ theorem proving platform. ==== GraphQL (`+graphql/+`) * *Framework:* Rust + async-graphql + axum -* *Port:* 8081 -* *Playground:* http://localhost:8081/ +* *Port:* 8082 +* *Playground:* http://localhost:8082/ * *Features:* ** Type-safe schema with all 17 provers ** Queries: `+provers+`, `+proofState+`, `+listProofs+`, @@ -79,7 +79,7 @@ ECHIDNA Core (Rust) └── Parallel Layer (Chapel) ↓ Interface Layer -├── GraphQL Server → Port 8081 +├── GraphQL Server → Port 8082 ├── gRPC Server → Port 50051 └── REST Server → Port 8000 ↓ diff --git a/src/rust/ipc/mod.rs b/src/rust/ipc/mod.rs index d2ad3a91..92945514 100644 --- a/src/rust/ipc/mod.rs +++ b/src/rust/ipc/mod.rs @@ -76,14 +76,16 @@ impl Transport { match self { Transport::Uds { path } => path.exists(), Transport::Tcp { host, port } => { - use std::net::{SocketAddr, TcpStream}; + use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; - let Ok(address) = format!("{}:{}", host, port).parse::() else { - // A malformed requested endpoint is unavailable. Probing a - // different fallback service would report false readiness. + // Resolve only the configured endpoint. A resolution failure + // must not probe a different fallback service. + let Ok(mut addresses) = (host.as_str(), *port).to_socket_addrs() else { return false; }; - TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() + addresses.any(|address| { + TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() + }) }, } } @@ -194,6 +196,28 @@ mod tests { .probe()); } + #[test] + fn test_transport_tcp_resolves_localhost() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(Transport::Tcp { + host: "localhost".into(), + port + } + .probe()); + } + + #[test] + fn test_transport_tcp_accepts_unbracketed_ipv6() { + let listener = std::net::TcpListener::bind(("::1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(Transport::Tcp { + host: "::1".into(), + port + } + .probe()); + } + #[test] fn test_ipc_client_gnn_rank_stub_errors() { let rt = tokio::runtime::Runtime::new().unwrap(); diff --git a/src/rust/provers/faial.rs b/src/rust/provers/faial.rs index 3457cf5a..cc340bb6 100644 --- a/src/rust/provers/faial.rs +++ b/src/rust/provers/faial.rs @@ -125,20 +125,27 @@ impl ProverBackend for FaialBackend { } async fn version(&self) -> Result { - let output = Command::new(self.binary()).arg("--version").output().await; - match output { - Ok(out) => { - let text = String::from_utf8_lossy(&out.stdout); - let text_err = String::from_utf8_lossy(&out.stderr); - let combined = format!("{}{}", text, text_err).trim().to_string(); - if combined.is_empty() { - Ok("faial@unknown-version".to_string()) - } else { - Ok(combined) - } - }, - Err(_) => Ok("faial@not-installed".to_string()), + // Version discovery is a short availability probe, not a proof run. + let limit = std::time::Duration::from_secs(self.config.timeout.min(10)); + let mut command = Command::new(self.binary()); + command + .arg("--version") + .stdin(Stdio::null()) + .kill_on_drop(true); + let output = tokio::time::timeout(limit, command.output()) + .await + .with_context(|| format!("Faial: --version timed out after {}s", limit.as_secs()))? + .context("Faial: failed to execute --version")?; + if !output.status.success() { + return Err(anyhow!("Faial: --version exited with {}", output.status)); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let version = format!("{}\n{}", stdout, stderr).trim().to_string(); + if version.is_empty() { + return Err(anyhow!("Faial: --version returned empty output")); } + Ok(version) } async fn parse_file(&self, path: PathBuf) -> Result { @@ -334,6 +341,125 @@ impl ProverBackend for FaialBackend { mod tests { use super::*; + #[tokio::test] + async fn version_rejects_a_missing_executable() { + let directory = tempfile::tempdir().unwrap(); + let backend = FaialBackend::new(ProverConfig { + executable: directory.path().join("missing-prover"), + timeout: 5, + ..ProverConfig::default() + }); + let error = backend.version().await.unwrap_err(); + assert!(error.to_string().contains("failed to execute --version")); + } + + #[cfg(unix)] + fn version_fixture(script: &str, timeout: u64) -> (tempfile::TempDir, FaialBackend) { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("version-probe"); + std::fs::write( + &executable, + format!( + "#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--version' ] || exit 64\n{script}\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let backend = FaialBackend::new(ProverConfig { + executable, + timeout, + ..ProverConfig::default() + }); + (directory, backend) + } + + #[cfg(unix)] + #[tokio::test] + async fn version_accepts_successful_stdout_or_stderr() { + for script in [ + "printf 'fixture version 1.2\\n'", + "printf 'fixture version 1.2\\n' >&2", + ] { + let (_directory, backend) = version_fixture(script, 5); + assert_eq!(backend.version().await.unwrap(), "fixture version 1.2"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn version_rejects_failed_or_empty_output() { + for (script, expected) in [ + ("printf 'fixture version 1.2\\n'; exit 7", "exited with"), + ("exit 0", "empty output"), + ("printf ' \\n\\t'; printf '\\n ' >&2", "empty output"), + ] { + let (_directory, backend) = version_fixture(script, 5); + let error = backend.version().await.unwrap_err(); + assert!(error.to_string().contains(expected), "{error:#}"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn version_rejects_a_non_executable_file() { + use std::os::unix::fs::PermissionsExt; + + let (_directory, backend) = version_fixture("printf 'fixture version 1.2\\n'", 5); + std::fs::set_permissions(backend.binary(), std::fs::Permissions::from_mode(0o600)) + .unwrap(); + let error = backend.version().await.unwrap_err(); + assert!(error.to_string().contains("failed to execute --version")); + } + + #[cfg(unix)] + #[tokio::test] + async fn version_times_out_and_terminates_the_process() { + use std::time::Duration; + + let (_directory, backend) = + version_fixture("printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", 1); + // Bound the regression itself if the implementation loses its deadline. + let result = tokio::time::timeout(Duration::from_secs(3), backend.version()).await; + let pid_path = backend.binary().with_extension("pid"); + let pid = std::fs::read_to_string(pid_path) + .unwrap() + .parse::() + .unwrap() + .to_string(); + let running = || { + std::process::Command::new("/bin/kill") + .args(["-0", pid.as_str()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("inspect the fixture process") + .success() + }; + let mut still_running = running(); + for _ in 0..50 { + if !still_running { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + still_running = running(); + } + // Clean up a deliberately defective implementation before asserting. + if still_running { + std::process::Command::new("/bin/kill") + .args(["-9", pid.as_str()]) + .status() + .expect("clean up the fixture process"); + } + assert!(!still_running, "the timed-out version process survived"); + let error = result + .expect("version must enforce its own deadline") + .unwrap_err(); + assert!(error.to_string().contains("--version timed out")); + } + #[test] fn kind_is_faial() { let backend = FaialBackend::new(ProverConfig::default()); diff --git a/src/rust/provers/gpuverify.rs b/src/rust/provers/gpuverify.rs index ab95282b..d3afe001 100644 --- a/src/rust/provers/gpuverify.rs +++ b/src/rust/provers/gpuverify.rs @@ -148,20 +148,27 @@ impl ProverBackend for GpuVerifyBackend { } async fn version(&self) -> Result { - let output = Command::new(self.binary()).arg("--version").output().await; - match output { - Ok(out) => { - let text = String::from_utf8_lossy(&out.stdout); - let text_err = String::from_utf8_lossy(&out.stderr); - let combined = format!("{}{}", text, text_err).trim().to_string(); - if combined.is_empty() { - Ok("gpuverify@unknown-version".to_string()) - } else { - Ok(combined) - } - }, - Err(_) => Ok("gpuverify@not-installed".to_string()), + // Version discovery is a short availability probe, not a proof run. + let limit = std::time::Duration::from_secs(self.config.timeout.min(10)); + let mut command = Command::new(self.binary()); + command + .arg("--version") + .stdin(Stdio::null()) + .kill_on_drop(true); + let output = tokio::time::timeout(limit, command.output()) + .await + .with_context(|| format!("GPUVerify: --version timed out after {}s", limit.as_secs()))? + .context("GPUVerify: failed to execute --version")?; + if !output.status.success() { + return Err(anyhow!("GPUVerify: --version exited with {}", output.status)); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let version = format!("{}\n{}", stdout, stderr).trim().to_string(); + if version.is_empty() { + return Err(anyhow!("GPUVerify: --version returned empty output")); } + Ok(version) } async fn parse_file(&self, path: PathBuf) -> Result { @@ -365,6 +372,125 @@ impl ProverBackend for GpuVerifyBackend { mod tests { use super::*; + #[tokio::test] + async fn version_rejects_a_missing_executable() { + let directory = tempfile::tempdir().unwrap(); + let backend = GpuVerifyBackend::new(ProverConfig { + executable: directory.path().join("missing-prover"), + timeout: 5, + ..ProverConfig::default() + }); + let error = backend.version().await.unwrap_err(); + assert!(error.to_string().contains("failed to execute --version")); + } + + #[cfg(unix)] + fn version_fixture(script: &str, timeout: u64) -> (tempfile::TempDir, GpuVerifyBackend) { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("version-probe"); + std::fs::write( + &executable, + format!( + "#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--version' ] || exit 64\n{script}\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let backend = GpuVerifyBackend::new(ProverConfig { + executable, + timeout, + ..ProverConfig::default() + }); + (directory, backend) + } + + #[cfg(unix)] + #[tokio::test] + async fn version_accepts_successful_stdout_or_stderr() { + for script in [ + "printf 'fixture version 1.2\\n'", + "printf 'fixture version 1.2\\n' >&2", + ] { + let (_directory, backend) = version_fixture(script, 5); + assert_eq!(backend.version().await.unwrap(), "fixture version 1.2"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn version_rejects_failed_or_empty_output() { + for (script, expected) in [ + ("printf 'fixture version 1.2\\n'; exit 7", "exited with"), + ("exit 0", "empty output"), + ("printf ' \\n\\t'; printf '\\n ' >&2", "empty output"), + ] { + let (_directory, backend) = version_fixture(script, 5); + let error = backend.version().await.unwrap_err(); + assert!(error.to_string().contains(expected), "{error:#}"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn version_rejects_a_non_executable_file() { + use std::os::unix::fs::PermissionsExt; + + let (_directory, backend) = version_fixture("printf 'fixture version 1.2\\n'", 5); + std::fs::set_permissions(backend.binary(), std::fs::Permissions::from_mode(0o600)) + .unwrap(); + let error = backend.version().await.unwrap_err(); + assert!(error.to_string().contains("failed to execute --version")); + } + + #[cfg(unix)] + #[tokio::test] + async fn version_times_out_and_terminates_the_process() { + use std::time::Duration; + + let (_directory, backend) = + version_fixture("printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", 1); + // Bound the regression itself if the implementation loses its deadline. + let result = tokio::time::timeout(Duration::from_secs(3), backend.version()).await; + let pid_path = backend.binary().with_extension("pid"); + let pid = std::fs::read_to_string(pid_path) + .unwrap() + .parse::() + .unwrap() + .to_string(); + let running = || { + std::process::Command::new("/bin/kill") + .args(["-0", pid.as_str()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("inspect the fixture process") + .success() + }; + let mut still_running = running(); + for _ in 0..50 { + if !still_running { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + still_running = running(); + } + // Clean up a deliberately defective implementation before asserting. + if still_running { + std::process::Command::new("/bin/kill") + .args(["-9", pid.as_str()]) + .status() + .expect("clean up the fixture process"); + } + assert!(!still_running, "the timed-out version process survived"); + let error = result + .expect("version must enforce its own deadline") + .unwrap_err(); + assert!(error.to_string().contains("--version timed out")); + } + #[test] fn kind_is_gpuverify() { let backend = GpuVerifyBackend::new(ProverConfig::default()); From 6c193c7d744ea50d9995a25e8ff75ecbf8b34511 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:39:50 +0000 Subject: [PATCH 19/30] ci: apply official native lock for merged workflow dependencies Apply the unchanged six-hunk output from workflow-linter run41917538023, job126881062669, generated against exact source b7221aefb696111e06f47e39a5e20be463830bcf. The output blob 71c7b2ff5d044001c3728eb159cca9c2bdf802ba matches the logged patch. No native identity or checksum metadata was hand-edited. --- .github/workflows/actions.lock | 48 +++++++++++++++++----------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 411417ca..71c7b2ff 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -23,7 +23,7 @@ workflows: - 'actions/upload-artifact@v7.0.2' - 'dtolnay/rust-toolchain@v1' - 'mlugg/setup-zig@v2.2.1' - - 'swatinem/rust-cache@v2.9.2' + - 'swatinem/rust-cache@v2.9.3' '.github/workflows/codeql.yml': - 'actions/checkout@v7.0.1' - 'github/codeql-action@v4.38.2' @@ -37,7 +37,7 @@ workflows: '.github/workflows/formal-verification.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - - 'swatinem/rust-cache@v2.9.2' + - 'swatinem/rust-cache@v2.9.3' '.github/workflows/generator-generic-ossf-slsa3-publish.yml': - 'actions/checkout@v7.0.1' - 'slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' @@ -55,40 +55,40 @@ workflows: '.github/workflows/live-provers.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - - 'swatinem/rust-cache@v2.9.2' + - 'swatinem/rust-cache@v2.9.3' '.github/workflows/mirror.yml': - 'metadatastician/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' '.github/workflows/mvp-smoke.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - - 'swatinem/rust-cache@v2.9.2' - - 'taiki-e/install-action@v2.87.28' + - 'swatinem/rust-cache@v2.9.3' + - 'taiki-e/install-action@v2.87.32' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' - 'actions/upload-pages-artifact@v5.0.0' '.github/workflows/proof-safety.yml': - 'actions/checkout@v7.0.1' - - 'swatinem/rust-cache@v2.9.2' + - 'swatinem/rust-cache@v2.9.3' '.github/workflows/rust-ci.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - - 'swatinem/rust-cache@v2.9.2' + - 'swatinem/rust-cache@v2.9.3' '.github/workflows/s4-loop.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - - 'swatinem/rust-cache@v2.9.2' - - 'taiki-e/install-action@v2.87.28' + - 'swatinem/rust-cache@v2.9.3' + - 'taiki-e/install-action@v2.87.32' '.github/workflows/scorecard.yml': - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/secret-scanner.yml': - 'hyperpolymath/proven-cicd@a12b2b4644db1a1250bc50b58fd24c99f89f564e' '.github/workflows/security-scan.yml': - - 'hyperpolymath/panic-attack@8955f615f769204e42340e08bdea2de9d72e81cd' + - 'hyperpolymath/panic-attack@be6ac46716eaf049570ec4966f4ae4699b319689' '.github/workflows/server-boot-gate.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - - 'swatinem/rust-cache@v2.9.2' + - 'swatinem/rust-cache@v2.9.3' '.github/workflows/spark-theatre-gate.yml': - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/static-analysis-gate.yml': @@ -105,9 +105,9 @@ workflows: - 'hyperpolymath/cicd-suite@4c772eb235b04455307d5c03cbbc4f45130f7f0f' - 'mlugg/setup-zig@v2.2.1' dependencies: - 'Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': - ref: 'v2.9.2' - commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + 'Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0': + ref: 'v2.9.3' + commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' owner_id: 580492 repo_id: 298565987 'actions/attest-build-provenance@v4.2.2': @@ -234,13 +234,13 @@ dependencies: commit: 'sha1-4c772eb235b04455307d5c03cbbc4f45130f7f0f' owner_id: 6759885 repo_id: 1326697643 - 'hyperpolymath/panic-attack@8955f615f769204e42340e08bdea2de9d72e81cd': - ref: '8955f615f769204e42340e08bdea2de9d72e81cd' - commit: 'sha1-8955f615f769204e42340e08bdea2de9d72e81cd' + 'hyperpolymath/panic-attack@be6ac46716eaf049570ec4966f4ae4699b319689': + ref: 'be6ac46716eaf049570ec4966f4ae4699b319689' + commit: 'sha1-be6ac46716eaf049570ec4966f4ae4699b319689' owner_id: 6759885 repo_id: 1152434997 uses: - - 'Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + - 'Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' 'hyperpolymath/proven-cicd@a12b2b4644db1a1250bc50b58fd24c99f89f564e': @@ -323,14 +323,14 @@ dependencies: commit: 'sha1-c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' owner_id: 2242 repo_id: 204253808 - 'swatinem/rust-cache@v2.9.2': - ref: 'v2.9.2' - commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + 'swatinem/rust-cache@v2.9.3': + ref: 'v2.9.3' + commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' owner_id: 580492 repo_id: 298565987 - 'taiki-e/install-action@v2.87.28': - ref: 'v2.87.28' - commit: 'sha1-1168f6810a3cb30a3514bf5756a3b7f0422c6df8' + 'taiki-e/install-action@v2.87.32': + ref: 'v2.87.32' + commit: 'sha1-5488ac5a951645ce95062214902ac4b6bc1a1037' owner_id: 43724913 repo_id: 442947557 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': From 36396f8178696a9c2b604941d2e1d208038d17cd Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:51:01 +0000 Subject: [PATCH 20/30] test: distinguish probe termination and unavailable IPv6 loopback Apply the three exact rustfmt hunks from hosted job126883599589. Change only test logic beyond that formatting: inspect process state with ps instead of treating kill -0 PID existence as liveness, retain the one-second polling window, and never reap Tokio-owned children. Known zombie/dead states count as terminated; malformed/failed inspection is an error, with cleanup attempted before assertions. Add classifier controls. Skip the IPv6 loopback probe only for AddrNotAvailable, Unsupported, or target-specific Linux GNU x86-64 EAFNOSUPPORT97. Rust1.99.1 leaves that errno uncategorized. Other bind failures remain failures; add rejecting controls. No production probe behavior or dependency changed. No local Rust build or test execution is claimed. A bounded local Python-owned live/zombie/reap observation could not qualify ps: it returned exit1 with `fatal library error, lookup self`, which remains an error case. All Python-owned children were cleaned up. Hosted qualification is required. --- src/rust/ipc/mod.rs | 45 ++++++++++++- src/rust/provers/faial.rs | 118 +++++++++++++++++++++++++------- src/rust/provers/gpuverify.rs | 123 +++++++++++++++++++++++++++------- 3 files changed, 238 insertions(+), 48 deletions(-) diff --git a/src/rust/ipc/mod.rs b/src/rust/ipc/mod.rs index 92945514..df4024c5 100644 --- a/src/rust/ipc/mod.rs +++ b/src/rust/ipc/mod.rs @@ -207,9 +207,52 @@ mod tests { .probe()); } + fn ipv6_loopback_unavailable(error: &std::io::Error) -> bool { + if matches!( + error.kind(), + std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported + ) { + return true; + } + // Linux x86-64 EAFNOSUPPORT is 97. Rust 1.99.1 leaves it + // Uncategorized, so ErrorKind::Unsupported alone misses it. + // Keep this raw-code exception target-specific; other bind errors fail. + cfg!(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64")) + && error.raw_os_error() == Some(97) + } + + #[test] + fn test_ipv6_loopback_unavailable_rejects_other_bind_errors() { + use std::io::{Error, ErrorKind}; + + for kind in [ErrorKind::AddrNotAvailable, ErrorKind::Unsupported] { + assert!(ipv6_loopback_unavailable(&Error::from(kind))); + } + for kind in [ + ErrorKind::AddrInUse, + ErrorKind::PermissionDenied, + ErrorKind::InvalidInput, + ErrorKind::Other, + ] { + assert!(!ipv6_loopback_unavailable(&Error::from(kind))); + } + assert_eq!( + ipv6_loopback_unavailable(&Error::from_raw_os_error(97)), + cfg!(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64")) + ); + assert!(!ipv6_loopback_unavailable(&Error::from_raw_os_error(12345))); + } + #[test] fn test_transport_tcp_accepts_unbracketed_ipv6() { - let listener = std::net::TcpListener::bind(("::1", 0)).unwrap(); + let listener = match std::net::TcpListener::bind(("::1", 0)) { + Ok(listener) => listener, + Err(error) if ipv6_loopback_unavailable(&error) => { + eprintln!("IPv6 loopback unavailable; probe test not run: {error}"); + return; + } + Err(error) => panic!("bind IPv6 loopback fixture: {error}"), + }; let port = listener.local_addr().unwrap().port(); assert!(Transport::Tcp { host: "::1".into(), diff --git a/src/rust/provers/faial.rs b/src/rust/provers/faial.rs index cc340bb6..1983cdf6 100644 --- a/src/rust/provers/faial.rs +++ b/src/rust/provers/faial.rs @@ -407,12 +407,82 @@ mod tests { use std::os::unix::fs::PermissionsExt; let (_directory, backend) = version_fixture("printf 'fixture version 1.2\\n'", 5); - std::fs::set_permissions(backend.binary(), std::fs::Permissions::from_mode(0o600)) - .unwrap(); + std::fs::set_permissions(backend.binary(), std::fs::Permissions::from_mode(0o600)).unwrap(); let error = backend.version().await.unwrap_err(); assert!(error.to_string().contains("failed to execute --version")); } + #[cfg(unix)] + fn fixture_process_running(pid: &str) -> std::io::Result { + let output = std::process::Command::new("/bin/ps") + .args(["-o", "stat=", "-p", pid]) + .stdin(Stdio::null()) + .output()?; + fixture_process_state(output.status.code(), &output.stdout, &output.stderr) + } + + #[cfg(unix)] + fn fixture_process_state( + exit_code: Option, + stdout: &[u8], + stderr: &[u8], + ) -> std::io::Result { + let invalid = || { + std::io::Error::other(format!( + "cannot inspect fixture process: ps exit={exit_code:?}, stdout={stdout:?}, stderr={stderr:?}" + )) + }; + if !stderr.is_empty() { + return Err(invalid()); + } + if exit_code == Some(1) && stdout.iter().all(u8::is_ascii_whitespace) { + return Ok(false); + } + if exit_code != Some(0) { + return Err(invalid()); + } + let text = std::str::from_utf8(stdout).map_err(|_| invalid())?; + let mut fields = text.split_whitespace(); + let state = fields.next().ok_or_else(invalid)?; + if fields.next().is_some() + || !state.as_bytes()[1..] + .iter() + .all(|flag| matches!(flag, b'<' | b'N' | b'L' | b's' | b'l' | b'+')) + { + return Err(invalid()); + } + match state.as_bytes()[0] { + b'Z' | b'X' => Ok(false), + b'R' | b'S' | b'D' | b'T' | b't' | b'I' | b'W' => Ok(true), + _ => Err(invalid()), + } + } + + #[cfg(unix)] + #[test] + fn fixture_process_state_distinguishes_termination_from_inspection_errors() { + for state in ["R\n", "S+\n", "D\n", "T\n", "t\n", "I\n", "W\n"] { + assert!(fixture_process_state(Some(0), state.as_bytes(), b"").unwrap()); + } + for state in ["Z\n", "Zs\n", "X\n"] { + assert!(!fixture_process_state(Some(0), state.as_bytes(), b"").unwrap()); + } + assert!(!fixture_process_state(Some(1), b"", b"").unwrap()); + for (exit_code, stdout, stderr) in [ + (Some(0), b"".as_slice(), b"".as_slice()), + (Some(0), b"S\nZ\n".as_slice(), b"".as_slice()), + (Some(0), b"Zgarbage\n".as_slice(), b"".as_slice()), + (Some(0), b"?\n".as_slice(), b"".as_slice()), + (Some(0), b"\xff".as_slice(), b"".as_slice()), + (Some(1), b"".as_slice(), b"fatal library error".as_slice()), + (Some(1), b"Z\n".as_slice(), b"".as_slice()), + (Some(2), b"Z\n".as_slice(), b"".as_slice()), + (None, b"Z\n".as_slice(), b"".as_slice()), + ] { + assert!(fixture_process_state(exit_code, stdout, stderr).is_err()); + } + } + #[cfg(unix)] #[tokio::test] async fn version_times_out_and_terminates_the_process() { @@ -428,32 +498,34 @@ mod tests { .parse::() .unwrap() .to_string(); - let running = || { - std::process::Command::new("/bin/kill") - .args(["-0", pid.as_str()]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .expect("inspect the fixture process") - .success() - }; - let mut still_running = running(); + // A killed but unreaped child is terminated. Tokio owns reaping; + // PID existence (kill -0) must not impose a second cleanup deadline. + let mut state = fixture_process_running(&pid); for _ in 0..50 { - if !still_running { + if !matches!(&state, Ok(true)) { break; } tokio::time::sleep(Duration::from_millis(20)).await; - still_running = running(); + state = fixture_process_running(&pid); } - // Clean up a deliberately defective implementation before asserting. - if still_running { - std::process::Command::new("/bin/kill") - .args(["-9", pid.as_str()]) - .status() - .expect("clean up the fixture process"); - } - assert!(!still_running, "the timed-out version process survived"); + // Attempt cleanup before either a liveness or inspection assertion. + // Do not reap a child owned by Tokio. + let cleanup = if matches!(&state, Ok(false)) { + None + } else { + Some( + std::process::Command::new("/bin/kill") + .args(["-9", pid.as_str()]) + .status(), + ) + }; + let still_running = state.unwrap_or_else(|error| { + panic!("inspect the fixture process: {error}; cleanup attempt: {cleanup:?}") + }); + assert!( + !still_running, + "the timed-out version process survived; cleanup attempt: {cleanup:?}" + ); let error = result .expect("version must enforce its own deadline") .unwrap_err(); diff --git a/src/rust/provers/gpuverify.rs b/src/rust/provers/gpuverify.rs index d3afe001..f994ce82 100644 --- a/src/rust/provers/gpuverify.rs +++ b/src/rust/provers/gpuverify.rs @@ -160,7 +160,10 @@ impl ProverBackend for GpuVerifyBackend { .with_context(|| format!("GPUVerify: --version timed out after {}s", limit.as_secs()))? .context("GPUVerify: failed to execute --version")?; if !output.status.success() { - return Err(anyhow!("GPUVerify: --version exited with {}", output.status)); + return Err(anyhow!( + "GPUVerify: --version exited with {}", + output.status + )); } let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -438,12 +441,82 @@ mod tests { use std::os::unix::fs::PermissionsExt; let (_directory, backend) = version_fixture("printf 'fixture version 1.2\\n'", 5); - std::fs::set_permissions(backend.binary(), std::fs::Permissions::from_mode(0o600)) - .unwrap(); + std::fs::set_permissions(backend.binary(), std::fs::Permissions::from_mode(0o600)).unwrap(); let error = backend.version().await.unwrap_err(); assert!(error.to_string().contains("failed to execute --version")); } + #[cfg(unix)] + fn fixture_process_running(pid: &str) -> std::io::Result { + let output = std::process::Command::new("/bin/ps") + .args(["-o", "stat=", "-p", pid]) + .stdin(Stdio::null()) + .output()?; + fixture_process_state(output.status.code(), &output.stdout, &output.stderr) + } + + #[cfg(unix)] + fn fixture_process_state( + exit_code: Option, + stdout: &[u8], + stderr: &[u8], + ) -> std::io::Result { + let invalid = || { + std::io::Error::other(format!( + "cannot inspect fixture process: ps exit={exit_code:?}, stdout={stdout:?}, stderr={stderr:?}" + )) + }; + if !stderr.is_empty() { + return Err(invalid()); + } + if exit_code == Some(1) && stdout.iter().all(u8::is_ascii_whitespace) { + return Ok(false); + } + if exit_code != Some(0) { + return Err(invalid()); + } + let text = std::str::from_utf8(stdout).map_err(|_| invalid())?; + let mut fields = text.split_whitespace(); + let state = fields.next().ok_or_else(invalid)?; + if fields.next().is_some() + || !state.as_bytes()[1..] + .iter() + .all(|flag| matches!(flag, b'<' | b'N' | b'L' | b's' | b'l' | b'+')) + { + return Err(invalid()); + } + match state.as_bytes()[0] { + b'Z' | b'X' => Ok(false), + b'R' | b'S' | b'D' | b'T' | b't' | b'I' | b'W' => Ok(true), + _ => Err(invalid()), + } + } + + #[cfg(unix)] + #[test] + fn fixture_process_state_distinguishes_termination_from_inspection_errors() { + for state in ["R\n", "S+\n", "D\n", "T\n", "t\n", "I\n", "W\n"] { + assert!(fixture_process_state(Some(0), state.as_bytes(), b"").unwrap()); + } + for state in ["Z\n", "Zs\n", "X\n"] { + assert!(!fixture_process_state(Some(0), state.as_bytes(), b"").unwrap()); + } + assert!(!fixture_process_state(Some(1), b"", b"").unwrap()); + for (exit_code, stdout, stderr) in [ + (Some(0), b"".as_slice(), b"".as_slice()), + (Some(0), b"S\nZ\n".as_slice(), b"".as_slice()), + (Some(0), b"Zgarbage\n".as_slice(), b"".as_slice()), + (Some(0), b"?\n".as_slice(), b"".as_slice()), + (Some(0), b"\xff".as_slice(), b"".as_slice()), + (Some(1), b"".as_slice(), b"fatal library error".as_slice()), + (Some(1), b"Z\n".as_slice(), b"".as_slice()), + (Some(2), b"Z\n".as_slice(), b"".as_slice()), + (None, b"Z\n".as_slice(), b"".as_slice()), + ] { + assert!(fixture_process_state(exit_code, stdout, stderr).is_err()); + } + } + #[cfg(unix)] #[tokio::test] async fn version_times_out_and_terminates_the_process() { @@ -459,32 +532,34 @@ mod tests { .parse::() .unwrap() .to_string(); - let running = || { - std::process::Command::new("/bin/kill") - .args(["-0", pid.as_str()]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .expect("inspect the fixture process") - .success() - }; - let mut still_running = running(); + // A killed but unreaped child is terminated. Tokio owns reaping; + // PID existence (kill -0) must not impose a second cleanup deadline. + let mut state = fixture_process_running(&pid); for _ in 0..50 { - if !still_running { + if !matches!(&state, Ok(true)) { break; } tokio::time::sleep(Duration::from_millis(20)).await; - still_running = running(); + state = fixture_process_running(&pid); } - // Clean up a deliberately defective implementation before asserting. - if still_running { - std::process::Command::new("/bin/kill") - .args(["-9", pid.as_str()]) - .status() - .expect("clean up the fixture process"); - } - assert!(!still_running, "the timed-out version process survived"); + // Attempt cleanup before either a liveness or inspection assertion. + // Do not reap a child owned by Tokio. + let cleanup = if matches!(&state, Ok(false)) { + None + } else { + Some( + std::process::Command::new("/bin/kill") + .args(["-9", pid.as_str()]) + .status(), + ) + }; + let still_running = state.unwrap_or_else(|error| { + panic!("inspect the fixture process: {error}; cleanup attempt: {cleanup:?}") + }); + assert!( + !still_running, + "the timed-out version process survived; cleanup attempt: {cleanup:?}" + ); let error = result .expect("version must enforce its own deadline") .unwrap_err(); From 094f130293d852ab97092adbcec75295162191f3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:58:40 +0000 Subject: [PATCH 21/30] fix: bound Chuffed help probes and preserve child cleanup Apply the same one-to-ten-second probe timeout and kill-on-drop contract used by the repaired GPU backends. Close stdin and retain Chuffed's honest version-unavailable banner only after successful nonempty help output. Add process success, missing/nonexecutable, failed/empty, timeout and inspection controls. These new controls await hosted execution. Apply the exact three IPC rustfmt changes reported by job126889993047. --- src/rust/ipc/mod.rs | 15 ++- src/rust/provers/chuffed.rs | 208 +++++++++++++++++++++++++++++++++++- 2 files changed, 216 insertions(+), 7 deletions(-) diff --git a/src/rust/ipc/mod.rs b/src/rust/ipc/mod.rs index df4024c5..58d1190c 100644 --- a/src/rust/ipc/mod.rs +++ b/src/rust/ipc/mod.rs @@ -217,8 +217,11 @@ mod tests { // Linux x86-64 EAFNOSUPPORT is 97. Rust 1.99.1 leaves it // Uncategorized, so ErrorKind::Unsupported alone misses it. // Keep this raw-code exception target-specific; other bind errors fail. - cfg!(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64")) - && error.raw_os_error() == Some(97) + cfg!(all( + target_os = "linux", + target_env = "gnu", + target_arch = "x86_64" + )) && error.raw_os_error() == Some(97) } #[test] @@ -238,7 +241,11 @@ mod tests { } assert_eq!( ipv6_loopback_unavailable(&Error::from_raw_os_error(97)), - cfg!(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64")) + cfg!(all( + target_os = "linux", + target_env = "gnu", + target_arch = "x86_64" + )) ); assert!(!ipv6_loopback_unavailable(&Error::from_raw_os_error(12345))); } @@ -250,7 +257,7 @@ mod tests { Err(error) if ipv6_loopback_unavailable(&error) => { eprintln!("IPv6 loopback unavailable; probe test not run: {error}"); return; - } + }, Err(error) => panic!("bind IPv6 loopback fixture: {error}"), }; let port = listener.local_addr().unwrap().port(); diff --git a/src/rust/provers/chuffed.rs b/src/rust/provers/chuffed.rs index 4566a59e..879e33ba 100644 --- a/src/rust/provers/chuffed.rs +++ b/src/rust/provers/chuffed.rs @@ -45,10 +45,14 @@ impl ProverBackend for ChuffedBackend { async fn version(&self) -> Result { // Upstream fzn-chuffed has no --version option. Require a successful // real process response instead of returning a constant without exec. - let output = Command::new(&self.config.executable) - .arg("--help") - .output() + let limit = std::time::Duration::from_secs(self.config.timeout.clamp(1, 10)); + let mut command = Command::new(&self.config.executable); + command.arg("--help"); + command.stdin(Stdio::null()); + command.kill_on_drop(true); + let output = tokio::time::timeout(limit, command.output()) .await + .with_context(|| format!("fzn-chuffed --help timed out after {}s", limit.as_secs()))? .context("Failed to execute fzn-chuffed --help")?; anyhow::ensure!(output.status.success(), "fzn-chuffed --help failed"); let help = String::from_utf8_lossy(&output.stdout); @@ -229,4 +233,202 @@ mod tests { }); assert!(b.parse_result("----------").unwrap()); } + + #[cfg(unix)] + fn version_fixture(script: &str, timeout: u64) -> (tempfile::TempDir, ChuffedBackend) { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("version-probe"); + std::fs::write( + &executable, + format!("#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--help' ] || exit 64\n{script}\n"), + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let backend = ChuffedBackend::new(ProverConfig { + executable, + timeout, + ..ProverConfig::default() + }); + (directory, backend) + } + + #[cfg(unix)] + #[tokio::test] + async fn version_requires_a_successful_help_response_and_closed_stdin() { + let (_directory, backend) = version_fixture( + "if read -r line; then exit 65; fi\nprintf '\\nfixture usage\\nmore help\\n'", + 5, + ); + assert_eq!( + backend.version().await.unwrap(), + "Chuffed (version unavailable; --help): fixture usage" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn version_rejects_failed_empty_or_stderr_only_help() { + for (script, expected) in [ + ("printf 'usage\\n'; exit 7", "--help failed"), + ("exit 0", "--help returned no output"), + ("printf ' \\n\\t'", "--help returned no output"), + ("printf 'usage\\n' >&2", "--help returned no output"), + ] { + let (_directory, backend) = version_fixture(script, 5); + let error = backend.version().await.unwrap_err(); + assert!(error.to_string().contains(expected), "{error:#}"); + } + } + + #[tokio::test] + async fn version_rejects_a_missing_executable() { + let directory = tempfile::tempdir().unwrap(); + let backend = ChuffedBackend::new(ProverConfig { + executable: directory.path().join("missing-chuffed"), + ..ProverConfig::default() + }); + let error = backend.version().await.unwrap_err(); + assert!(error + .to_string() + .contains("Failed to execute fzn-chuffed --help")); + } + + #[cfg(unix)] + #[tokio::test] + async fn version_rejects_a_non_executable_file() { + use std::os::unix::fs::PermissionsExt; + + let (_directory, backend) = version_fixture("printf 'usage\\n'", 5); + std::fs::set_permissions( + &backend.config.executable, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let error = backend.version().await.unwrap_err(); + assert!(error + .to_string() + .contains("Failed to execute fzn-chuffed --help")); + } + + #[cfg(unix)] + fn fixture_process_running(pid: &str) -> std::io::Result { + let output = std::process::Command::new("/bin/ps") + .args(["-o", "stat=", "-p", pid]) + .stdin(Stdio::null()) + .output()?; + fixture_process_state(output.status.code(), &output.stdout, &output.stderr) + } + + #[cfg(unix)] + fn fixture_process_state( + exit_code: Option, + stdout: &[u8], + stderr: &[u8], + ) -> std::io::Result { + let invalid = || { + std::io::Error::other(format!( + "cannot inspect fixture process: ps exit={exit_code:?}, stdout={stdout:?}, stderr={stderr:?}" + )) + }; + if !stderr.is_empty() { + return Err(invalid()); + } + if exit_code == Some(1) && stdout.iter().all(u8::is_ascii_whitespace) { + return Ok(false); + } + if exit_code != Some(0) { + return Err(invalid()); + } + let text = std::str::from_utf8(stdout).map_err(|_| invalid())?; + let mut fields = text.split_whitespace(); + let state = fields.next().ok_or_else(invalid)?; + if fields.next().is_some() + || !state.as_bytes()[1..] + .iter() + .all(|flag| matches!(flag, b'<' | b'N' | b'L' | b's' | b'l' | b'+')) + { + return Err(invalid()); + } + match state.as_bytes()[0] { + b'Z' | b'X' => Ok(false), + b'R' | b'S' | b'D' | b'T' | b't' | b'I' | b'W' => Ok(true), + _ => Err(invalid()), + } + } + + #[cfg(unix)] + #[test] + fn fixture_process_state_distinguishes_termination_from_inspection_errors() { + for state in ["R\n", "S+\n", "D\n", "T\n", "t\n", "I\n", "W\n"] { + assert!(fixture_process_state(Some(0), state.as_bytes(), b"").unwrap()); + } + for state in ["Z\n", "Zs\n", "X\n"] { + assert!(!fixture_process_state(Some(0), state.as_bytes(), b"").unwrap()); + } + assert!(!fixture_process_state(Some(1), b"", b"").unwrap()); + for (exit_code, stdout, stderr) in [ + (Some(0), b"".as_slice(), b"".as_slice()), + (Some(0), b"S\nZ\n".as_slice(), b"".as_slice()), + (Some(0), b"Zgarbage\n".as_slice(), b"".as_slice()), + (Some(0), b"?\n".as_slice(), b"".as_slice()), + (Some(0), b"\xff".as_slice(), b"".as_slice()), + (Some(1), b"".as_slice(), b"fatal library error".as_slice()), + (Some(1), b"Z\n".as_slice(), b"".as_slice()), + (Some(2), b"Z\n".as_slice(), b"".as_slice()), + (None, b"Z\n".as_slice(), b"".as_slice()), + ] { + assert!(fixture_process_state(exit_code, stdout, stderr).is_err()); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn version_times_out_and_terminates_the_process() { + use std::time::Duration; + + let (_directory, backend) = + version_fixture("printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", 1); + // Bound the regression itself if the implementation loses its deadline. + let result = tokio::time::timeout(Duration::from_secs(3), backend.version()).await; + let pid_path = backend.config.executable.with_extension("pid"); + let pid = std::fs::read_to_string(pid_path) + .unwrap() + .parse::() + .unwrap() + .to_string(); + // A killed but unreaped child is terminated. Tokio owns reaping; + // PID existence (kill -0) must not impose a second cleanup deadline. + let mut state = fixture_process_running(&pid); + for _ in 0..50 { + if !matches!(&state, Ok(true)) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + state = fixture_process_running(&pid); + } + // Attempt cleanup before either a liveness or inspection assertion. + // Do not reap a child owned by Tokio. + let cleanup = if matches!(&state, Ok(false)) { + None + } else { + Some( + std::process::Command::new("/bin/kill") + .args(["-9", pid.as_str()]) + .status(), + ) + }; + let still_running = state.unwrap_or_else(|error| { + panic!("inspect the fixture process: {error}; cleanup attempt: {cleanup:?}") + }); + assert!( + !still_running, + "the timed-out version process survived; cleanup attempt: {cleanup:?}" + ); + let error = result + .expect("version must enforce its own deadline") + .unwrap_err(); + assert!(error.to_string().contains("--help timed out")); + } } From 62a2ea04394e392c18445c9d233e1429c0c4dc5d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:11:00 +0000 Subject: [PATCH 22/30] fix: bound T1 version probes and isolate their process input Route CVC5 and six T1 version queries through one private Tokio subprocess boundary with a one-to-ten-second deadline, closed stdin and kill-on-drop. Preserve each backend's existing status and output interpretation. Close OpenSMT probe stdin while retaining its existing deadline and diagnostics. Exercise all eight actual factory backends with a finite sleeping probe, requiring their own deadline error and child termination before assertions. Retain real-process raw-output, readable-parent-stdin and external cancellation controls. No exact-source-text routing assertions. No proof execution, public API, dependencies or workflow changes. New Rust tests and formatting await hosted qualification; this candidate has not been locally compiled or executed. --- src/rust/provers/altergo.rs | 4 +- src/rust/provers/cvc5.rs | 5 +- src/rust/provers/eprover.rs | 4 +- src/rust/provers/glpk.rs | 5 +- src/rust/provers/minizinc.rs | 5 +- src/rust/provers/mod.rs | 1 + src/rust/provers/opensmt.rs | 1 + src/rust/provers/vampire.rs | 4 +- src/rust/provers/version_probe.rs | 372 ++++++++++++++++++++++++++++++ src/rust/provers/z3.rs | 4 +- 10 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 src/rust/provers/version_probe.rs diff --git a/src/rust/provers/altergo.rs b/src/rust/provers/altergo.rs index 2f887be7..2b2014df 100644 --- a/src/rust/provers/altergo.rs +++ b/src/rust/provers/altergo.rs @@ -96,9 +96,7 @@ impl ProverBackend for AltErgoBackend { } async fn version(&self) -> Result { - let output = Command::new(&self.config.executable) - .arg("--version") - .output() + let output = super::version_probe::run(&self.config, &["--version"]) .await .context("Failed to run alt-ergo --version")?; diff --git a/src/rust/provers/cvc5.rs b/src/rust/provers/cvc5.rs index dd8a14c8..39297a6f 100644 --- a/src/rust/provers/cvc5.rs +++ b/src/rust/provers/cvc5.rs @@ -534,9 +534,8 @@ impl ProverBackend for CVC5Backend { } async fn version(&self) -> Result { - let output = Command::new(&self.config.base.executable) - .arg("--version") - .output() + let output = super::version_probe::run(&self.config.base, &["--version"]) + .await .context("Failed to execute CVC5")?; let version_str = String::from_utf8_lossy(&output.stdout); Ok(version_str.lines().next().unwrap_or("unknown").to_string()) diff --git a/src/rust/provers/eprover.rs b/src/rust/provers/eprover.rs index 716198d3..b7e846d8 100644 --- a/src/rust/provers/eprover.rs +++ b/src/rust/provers/eprover.rs @@ -84,9 +84,7 @@ impl ProverBackend for EProverBackend { } async fn version(&self) -> Result { - let output = Command::new(&self.config.executable) - .arg("--version") - .output() + let output = super::version_probe::run(&self.config, &["--version"]) .await .context("Failed to run eprover --version")?; diff --git a/src/rust/provers/glpk.rs b/src/rust/provers/glpk.rs index fcfed149..880ae5db 100644 --- a/src/rust/provers/glpk.rs +++ b/src/rust/provers/glpk.rs @@ -40,10 +40,7 @@ impl ProverBackend for GLPKBackend { ProverKind::GLPK } async fn version(&self) -> Result { - let o = Command::new(&self.config.executable) - .arg("--version") - .output() - .await?; + let o = super::version_probe::run(&self.config, &["--version"]).await?; Ok(String::from_utf8_lossy(&o.stdout) .lines() .next() diff --git a/src/rust/provers/minizinc.rs b/src/rust/provers/minizinc.rs index 3c056b43..3a4fdb62 100644 --- a/src/rust/provers/minizinc.rs +++ b/src/rust/provers/minizinc.rs @@ -46,10 +46,7 @@ impl ProverBackend for MiniZincBackend { ProverKind::MiniZinc } async fn version(&self) -> Result { - let o = Command::new(&self.config.executable) - .arg("--version") - .output() - .await?; + let o = super::version_probe::run(&self.config, &["--version"]).await?; Ok(String::from_utf8_lossy(&o.stdout) .lines() .next() diff --git a/src/rust/provers/mod.rs b/src/rust/provers/mod.rs index 408ba12a..51a527c0 100644 --- a/src/rust/provers/mod.rs +++ b/src/rust/provers/mod.rs @@ -118,6 +118,7 @@ pub mod typed_wasm; pub mod uppaal; pub mod uppaal_stratego; pub mod vampire; +mod version_probe; pub mod viper; pub mod why3; pub mod z3; diff --git a/src/rust/provers/opensmt.rs b/src/rust/provers/opensmt.rs index d1083b33..e439330e 100644 --- a/src/rust/provers/opensmt.rs +++ b/src/rust/provers/opensmt.rs @@ -65,6 +65,7 @@ impl ProverBackend for OpenSmtBackend { std::time::Duration::from_secs(self.config.timeout.clamp(1, 30)), Command::new(&self.config.executable) .arg("--version") + .stdin(Stdio::null()) .kill_on_drop(true) .output(), ) diff --git a/src/rust/provers/vampire.rs b/src/rust/provers/vampire.rs index a424ff79..4876c0a8 100644 --- a/src/rust/provers/vampire.rs +++ b/src/rust/provers/vampire.rs @@ -83,9 +83,7 @@ impl ProverBackend for VampireBackend { } async fn version(&self) -> Result { - let output = Command::new(&self.config.executable) - .arg("--version") - .output() + let output = super::version_probe::run(&self.config, &["--version"]) .await .context("Failed to run vampire --version")?; diff --git a/src/rust/provers/version_probe.rs b/src/rust/provers/version_probe.rs new file mode 100644 index 00000000..72725980 --- /dev/null +++ b/src/rust/provers/version_probe.rs @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// SPDX-License-Identifier: MPL-2.0 +//! Subprocess lifetime boundary for short version probes. +//! +//! This runner returns the actual process status and output. Each backend keeps +//! its own interpretation of that response; this is not a proof result. +//! Cancellation terminates the direct child. Tokio owns its eventual reaping. + +use anyhow::{Context, Result}; +use std::process::{Output, Stdio}; +use std::time::Duration; +use tokio::process::Command; + +use super::ProverConfig; + +/// Run a version probe with closed stdin and a one-to-ten-second deadline. +/// +/// Keep this separate from proof execution: a version query must not block the +/// async runtime or consume the caller's input, even with a misbehaving binary. +pub(super) async fn run(config: &ProverConfig, args: &[&str]) -> Result { + let limit = Duration::from_secs(config.timeout.clamp(1, 10)); + let mut command = Command::new(&config.executable); + command.args(args).stdin(Stdio::null()).kill_on_drop(true); + tokio::time::timeout(limit, command.output()) + .await + .with_context(|| { + format!( + "Version probe at {} timed out after {}s", + config.executable.display(), + limit.as_secs() + ) + })? + .with_context(|| { + format!( + "Failed to execute version probe at {}", + config.executable.display() + ) + }) +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + mod process { + use super::super::*; + use crate::provers::{ProverFactory, ProverKind}; + use std::os::unix::fs::PermissionsExt; + use tokio::io::AsyncWriteExt; + + const CHILD_BACKEND: &str = "ECHIDNA_VERSION_PROBE_TEST_BACKEND"; + const CHILD_EXECUTABLE: &str = "ECHIDNA_VERSION_PROBE_TEST_EXECUTABLE"; + const CHILD_COMPLETE: &str = "ECHIDNA_VERSION_PROBE_CHILD_COMPLETE"; + + fn fixture(script: &str, timeout: u64) -> (tempfile::TempDir, ProverConfig) { + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("version-probe"); + std::fs::write( + &executable, + format!( + "#!/bin/sh\n[ \"$#\" -eq 1 ] && [ \"$1\" = '--version' ] || exit 64\n{script}\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)) + .unwrap(); + let config = ProverConfig { + executable, + timeout, + ..ProverConfig::default() + }; + (directory, config) + } + + fn kind(name: &str) -> ProverKind { + match name { + "z3" => ProverKind::Z3, + "cvc5" => ProverKind::CVC5, + "alt-ergo" => ProverKind::AltErgo, + "vampire" => ProverKind::Vampire, + "eprover" => ProverKind::EProver, + "glpk" => ProverKind::GLPK, + "minizinc" => ProverKind::MiniZinc, + "opensmt" => ProverKind::OpenSmt, + _ => panic!("unexpected fixture backend: {name}"), + } + } + + #[tokio::test] + async fn runner_preserves_real_status_and_both_output_streams() { + let (_directory, config) = fixture( + "printf 'stdout response\\n'; printf 'stderr response\\n' >&2; exit 7", + 5, + ); + let output = run(&config, &["--version"]).await.unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(output.stdout, b"stdout response\n"); + assert_eq!(output.stderr, b"stderr response\n"); + } + + #[tokio::test] + async fn runner_rejects_missing_and_non_executable_files() { + let (_directory, mut config) = fixture("exit 0", 5); + std::fs::set_permissions( + &config.executable, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + assert!(run(&config, &["--version"]).await.is_err()); + config.executable.set_file_name("missing-version-probe"); + assert!(run(&config, &["--version"]).await.is_err()); + } + + // Invoked by the parent test in a separate test-harness process so its + // stdin is deliberately readable. The normal test run has no child + // request; only the parent below claims to exercise this path. + #[tokio::test] + async fn child_probe_harness() { + let Ok(name) = std::env::var(CHILD_BACKEND) else { + return; + }; + let executable = std::env::var_os(CHILD_EXECUTABLE) + .expect("child fixture executable") + .into(); + let backend = ProverFactory::create( + kind(&name), + ProverConfig { + executable, + timeout: 1, + ..ProverConfig::default() + }, + ) + .unwrap(); + assert_eq!(backend.version().await.unwrap(), "fixture version 1.2"); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap(); + assert_eq!(input, "input reserved for the application\n"); + println!("{CHILD_COMPLETE}:{name}"); + } + + #[tokio::test] + async fn callers_close_stdin_even_when_the_parent_has_readable_input() { + let (_directory, config) = fixture( + "if IFS= read -r line; then printf 'consumed parent input\\n'; exit 65; fi\n\ + printf 'fixture version 1.2\\n'", + 1, + ); + let test_name = format!( + "{}::child_probe_harness", + module_path!().split_once("::").unwrap().1 + ); + for name in [ + "z3", "cvc5", "alt-ergo", "vampire", "eprover", "glpk", "minizinc", "opensmt", + ] { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name, "--nocapture"]) + .env(CHILD_BACKEND, name) + .env(CHILD_EXECUTABLE, &config.executable) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + input.write_all(b"input reserved for the application\n").await.unwrap(); + drop(input); + let output = tokio::time::timeout( + Duration::from_secs(5), + child.wait_with_output(), + ) + .await + .expect("child harness must finish within its deadline") + .expect("child harness process"); + assert!( + output.status.success(), + "{name}: stdout={}; stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let marker = format!("{CHILD_COMPLETE}:{name}"); + assert!( + String::from_utf8_lossy(&output.stdout).contains(&marker), + "{name}: the exact child test must actually run" + ); + } + } + + fn process_running(pid: &str) -> std::io::Result { + let output = std::process::Command::new("/bin/ps") + .args(["-o", "stat=", "-p", pid]) + .stdin(Stdio::null()) + .output()?; + process_state(output.status.code(), &output.stdout, &output.stderr) + } + + fn process_state( + exit_code: Option, + stdout: &[u8], + stderr: &[u8], + ) -> std::io::Result { + let invalid = || { + std::io::Error::other(format!( + "cannot inspect fixture process: ps exit={exit_code:?}, stdout={stdout:?}, stderr={stderr:?}" + )) + }; + if !stderr.is_empty() { + return Err(invalid()); + } + if exit_code == Some(1) && stdout.iter().all(u8::is_ascii_whitespace) { + return Ok(false); + } + if exit_code != Some(0) { + return Err(invalid()); + } + let text = std::str::from_utf8(stdout).map_err(|_| invalid())?; + let mut fields = text.split_whitespace(); + let state = fields.next().ok_or_else(invalid)?; + if fields.next().is_some() + || !state.as_bytes()[1..] + .iter() + .all(|flag| matches!(flag, b'<' | b'N' | b'L' | b's' | b'l' | b'+')) + { + return Err(invalid()); + } + match state.as_bytes()[0] { + b'Z' | b'X' => Ok(false), + b'R' | b'S' | b'D' | b'T' | b't' | b'I' | b'W' => Ok(true), + _ => Err(invalid()), + } + } + + #[test] + fn process_inspection_distinguishes_termination_from_unknown() { + for state in ["R\n", "S+\n", "D\n", "T\n", "t\n", "I\n", "W\n"] { + assert!(process_state(Some(0), state.as_bytes(), b"").unwrap()); + } + for state in ["Z\n", "Zs\n", "X\n"] { + assert!(!process_state(Some(0), state.as_bytes(), b"").unwrap()); + } + assert!(!process_state(Some(1), b"", b"").unwrap()); + for (exit_code, stdout, stderr) in [ + (Some(0), b"".as_slice(), b"".as_slice()), + (Some(0), b"S\nZ\n".as_slice(), b"".as_slice()), + (Some(0), b"Zgarbage\n".as_slice(), b"".as_slice()), + (Some(0), b"?\n".as_slice(), b"".as_slice()), + (Some(0), b"\xff".as_slice(), b"".as_slice()), + (Some(1), b"".as_slice(), b"fatal library error".as_slice()), + (Some(1), b"Z\n".as_slice(), b"".as_slice()), + (Some(2), b"Z\n".as_slice(), b"".as_slice()), + (None, b"Z\n".as_slice(), b"".as_slice()), + ] { + assert!(process_state(exit_code, stdout, stderr).is_err()); + } + } + + async fn assert_terminated(pid: &str) { + let mut state = process_running(pid); + for _ in 0..50 { + if !matches!(&state, Ok(true)) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + state = process_running(pid); + } + // Cleanup precedes assertions, including an unknown inspection. + // A zombie is terminated; Tokio, not this test, owns its reaping. + let cleanup = if matches!(&state, Ok(false)) { + None + } else { + Some( + std::process::Command::new("/bin/kill") + .args(["-9", pid]) + .status(), + ) + }; + let running = state.unwrap_or_else(|error| { + panic!("inspect the fixture process: {error}; cleanup attempt: {cleanup:?}") + }); + assert!( + !running, + "version probe process survived; cleanup attempt: {cleanup:?}" + ); + } + + fn fixture_pid(config: &ProverConfig) -> std::io::Result { + std::fs::read_to_string(config.executable.with_extension("pid"))? + .parse::() + .map(|pid| pid.to_string()) + .map_err(std::io::Error::other) + } + + #[tokio::test] + async fn runner_times_out_and_terminates_the_process() { + let (_directory, config) = fixture( + "printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", + 1, + ); + let result = tokio::time::timeout( + Duration::from_secs(3), + run(&config, &["--version"]), + ) + .await; + let pid = fixture_pid(&config).expect("fixture process started"); + assert_terminated(&pid).await; + let error = result + .expect("the runner must enforce its own shorter deadline") + .unwrap_err(); + assert!(error.to_string().contains("timed out after 1s")); + } + + #[tokio::test] + async fn factory_backends_enforce_their_own_probe_deadline_and_cleanup() { + for name in [ + "z3", "cvc5", "alt-ergo", "vampire", "eprover", "glpk", "minizinc", "opensmt", + ] { + // The finite sleep also bounds a regression to a blocking + // subprocess: Tokio cannot interrupt a future that never yields. + let (_directory, config) = fixture( + "printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 4", + 1, + ); + let backend = ProverFactory::create(kind(name), config.clone()).unwrap(); + let started = std::time::Instant::now(); + let result = tokio::time::timeout( + Duration::from_secs(3), + backend.version(), + ) + .await; + let elapsed = started.elapsed(); + let pid = fixture_pid(&config).expect("fixture process started"); + // Check termination and attempt cleanup before any deadline or + // result assertion, including a caller that bypasses the helper. + assert_terminated(&pid).await; + let error = result + .unwrap_or_else(|_| panic!("{name}: only the outer test timeout fired")) + .expect_err("the backend must reject its timed-out probe"); + assert!( + elapsed < Duration::from_secs(3), + "{name}: the backend blocked through its own deadline: {elapsed:?}" + ); + assert!( + format!("{error:#}").contains("timed out"), + "{name}: expected the backend's timeout error, got {error:#}" + ); + } + } + + #[tokio::test] + async fn caller_cancellation_terminates_an_in_flight_probe() { + let (_directory, config) = fixture( + "printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", + 10, + ); + let task_config = config.clone(); + let task = tokio::spawn(async move { run(&task_config, &["--version"]).await }); + let mut pid = fixture_pid(&config); + for _ in 0..100 { + if pid.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + pid = fixture_pid(&config); + } + task.abort(); + let result = task.await; + // If startup failed, abort still precedes any assertion. + let pid = pid.expect("fixture must start before cancellation"); + assert_terminated(&pid).await; + assert!(result.unwrap_err().is_cancelled()); + } + } +} diff --git a/src/rust/provers/z3.rs b/src/rust/provers/z3.rs index fae23f3f..38d9b9f4 100644 --- a/src/rust/provers/z3.rs +++ b/src/rust/provers/z3.rs @@ -233,9 +233,7 @@ impl ProverBackend for Z3Backend { } async fn version(&self) -> Result { - let output = Command::new(&self.config.executable) - .arg("--version") - .output() + let output = super::version_probe::run(&self.config, &["--version"]) .await .context("Failed to get Z3 version")?; From d92484fd2cb38c3f990267a4790d8a8dca74e3d2 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:22:45 +0000 Subject: [PATCH 23/30] style(provers): apply the hosted formatter's version probe output --- src/rust/provers/version_probe.rs | 57 +++++++++++-------------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/src/rust/provers/version_probe.rs b/src/rust/provers/version_probe.rs index 72725980..d50d012d 100644 --- a/src/rust/provers/version_probe.rs +++ b/src/rust/provers/version_probe.rs @@ -61,8 +61,7 @@ mod tests { ), ) .unwrap(); - std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)) - .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); let config = ProverConfig { executable, timeout, @@ -100,11 +99,8 @@ mod tests { #[tokio::test] async fn runner_rejects_missing_and_non_executable_files() { let (_directory, mut config) = fixture("exit 0", 5); - std::fs::set_permissions( - &config.executable, - std::fs::Permissions::from_mode(0o600), - ) - .unwrap(); + std::fs::set_permissions(&config.executable, std::fs::Permissions::from_mode(0o600)) + .unwrap(); assert!(run(&config, &["--version"]).await.is_err()); config.executable.set_file_name("missing-version-probe"); assert!(run(&config, &["--version"]).await.is_err()); @@ -162,15 +158,15 @@ mod tests { .spawn() .unwrap(); let mut input = child.stdin.take().unwrap(); - input.write_all(b"input reserved for the application\n").await.unwrap(); + input + .write_all(b"input reserved for the application\n") + .await + .unwrap(); drop(input); - let output = tokio::time::timeout( - Duration::from_secs(5), - child.wait_with_output(), - ) - .await - .expect("child harness must finish within its deadline") - .expect("child harness process"); + let output = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output()) + .await + .expect("child harness must finish within its deadline") + .expect("child harness process"); assert!( output.status.success(), "{name}: stdout={}; stderr={}", @@ -291,15 +287,10 @@ mod tests { #[tokio::test] async fn runner_times_out_and_terminates_the_process() { - let (_directory, config) = fixture( - "printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", - 1, - ); - let result = tokio::time::timeout( - Duration::from_secs(3), - run(&config, &["--version"]), - ) - .await; + let (_directory, config) = + fixture("printf '%s' \"$\" > \"$0.pid\"\nexec /bin/sleep 30", 1); + let result = + tokio::time::timeout(Duration::from_secs(3), run(&config, &["--version"])).await; let pid = fixture_pid(&config).expect("fixture process started"); assert_terminated(&pid).await; let error = result @@ -315,17 +306,11 @@ mod tests { ] { // The finite sleep also bounds a regression to a blocking // subprocess: Tokio cannot interrupt a future that never yields. - let (_directory, config) = fixture( - "printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 4", - 1, - ); + let (_directory, config) = + fixture("printf '%s' \"$\" > \"$0.pid\"\nexec /bin/sleep 4", 1); let backend = ProverFactory::create(kind(name), config.clone()).unwrap(); let started = std::time::Instant::now(); - let result = tokio::time::timeout( - Duration::from_secs(3), - backend.version(), - ) - .await; + let result = tokio::time::timeout(Duration::from_secs(3), backend.version()).await; let elapsed = started.elapsed(); let pid = fixture_pid(&config).expect("fixture process started"); // Check termination and attempt cleanup before any deadline or @@ -347,10 +332,8 @@ mod tests { #[tokio::test] async fn caller_cancellation_terminates_an_in_flight_probe() { - let (_directory, config) = fixture( - "printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", - 10, - ); + let (_directory, config) = + fixture("printf '%s' \"$\" > \"$0.pid\"\nexec /bin/sleep 30", 10); let task_config = config.clone(); let task = tokio::spawn(async move { run(&task_config, &["--version"]).await }); let mut pid = fixture_pid(&config); From 346a31df421e7dc4972119d71b49684a0e6ffda3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:27:42 +0000 Subject: [PATCH 24/30] test(provers): preserve shell PID expansion in timeout fixtures Restore the three literal $$ values changed by replacement-string expansion while applying formatter output. Production version-probe logic and all assertions remain unchanged. --- src/rust/provers/version_probe.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rust/provers/version_probe.rs b/src/rust/provers/version_probe.rs index d50d012d..1dd782ce 100644 --- a/src/rust/provers/version_probe.rs +++ b/src/rust/provers/version_probe.rs @@ -288,7 +288,7 @@ mod tests { #[tokio::test] async fn runner_times_out_and_terminates_the_process() { let (_directory, config) = - fixture("printf '%s' \"$\" > \"$0.pid\"\nexec /bin/sleep 30", 1); + fixture("printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", 1); let result = tokio::time::timeout(Duration::from_secs(3), run(&config, &["--version"])).await; let pid = fixture_pid(&config).expect("fixture process started"); @@ -307,7 +307,7 @@ mod tests { // The finite sleep also bounds a regression to a blocking // subprocess: Tokio cannot interrupt a future that never yields. let (_directory, config) = - fixture("printf '%s' \"$\" > \"$0.pid\"\nexec /bin/sleep 4", 1); + fixture("printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 4", 1); let backend = ProverFactory::create(kind(name), config.clone()).unwrap(); let started = std::time::Instant::now(); let result = tokio::time::timeout(Duration::from_secs(3), backend.version()).await; @@ -333,7 +333,7 @@ mod tests { #[tokio::test] async fn caller_cancellation_terminates_an_in_flight_probe() { let (_directory, config) = - fixture("printf '%s' \"$\" > \"$0.pid\"\nexec /bin/sleep 30", 10); + fixture("printf '%s' \"$$\" > \"$0.pid\"\nexec /bin/sleep 30", 10); let task_config = config.clone(); let task = tokio::spawn(async move { run(&task_config, &["--version"]).await }); let mut pid = fixture_pid(&config); From 3f475120eaefffec9c964f433e26d9f6a85bb526 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:39:26 +0000 Subject: [PATCH 25/30] fix(native): link the real Chapel runtime and preserve build input closure Separate real and stub bridge modes, initialize Chapel once, and require the compiler-selected native dependencies. Add the binary default-address control and include workspace/build inputs in both container recipes. Hosted qualification is required for these combined source changes. --- .github/workflows/chapel-ci.yml | 56 ++-- .machine_readable/descriptiles/STATE.a2ml | 12 + Containerfile | 3 +- Justfile | 18 +- build.rs | 44 +-- build_support/chapel.rs | 291 ++++++++++++++++++ container/Containerfile | 9 +- .../2026-05-30-chapel-pic-rebuild.adoc | 13 +- src/chapel/README.adoc | 97 +++--- src/interfaces/graphql/tests/startup.rs | 39 +++ src/rust/proof_search.rs | 75 ++++- src/zig_ffi/build.zig | 99 +++--- src/zig_ffi/chapel_bridge.zig | 38 +++ 13 files changed, 630 insertions(+), 164 deletions(-) create mode 100644 build_support/chapel.rs diff --git a/.github/workflows/chapel-ci.yml b/.github/workflows/chapel-ci.yml index 8c9316f0..41d6048e 100644 --- a/.github/workflows/chapel-ci.yml +++ b/.github/workflows/chapel-ci.yml @@ -11,6 +11,9 @@ on: - 'src/rust/dispatch.rs' - 'src/chapel/**' - 'Cargo.toml' + - 'build.rs' + - 'build_support/chapel.rs' + - 'Justfile' - '.github/workflows/chapel-ci.yml' pull_request: paths: @@ -19,6 +22,9 @@ on: - 'src/rust/dispatch.rs' - 'src/chapel/**' - 'Cargo.toml' + - 'build.rs' + - 'build_support/chapel.rs' + - 'Justfile' - '.github/workflows/chapel-ci.yml' # Cause-B mitigation (#77): cancel superseded runs so stacked pushes @@ -56,8 +62,8 @@ jobs: # shared object`. Until the CI image ships a PIC-enabled runtime # (or we adopt a from-source Chapel build via the # `chapel-pic-from-source` Justfile recipe), the metalayer is - # distributed as `libechidna_chapel.a` and the Zig FFI links it - # statically. + # distributed as `libechidna_chapel.a`; Cargo links it and the + # compiler-selected runtime after the static Zig bridge. chapel-build: name: Compile Chapel Metalayer runs-on: ubuntu-22.04 @@ -144,6 +150,8 @@ jobs: runs-on: ubuntu-22.04 timeout-minutes: 30 needs: zig-ffi + env: + ECHIDNA_CHAPEL_EXPECT_MODE: stubs continue-on-error: true # allow-fail: issue=#416 expires=2026-11-30 steps: - uses: actions/checkout@v7.0.1 @@ -170,19 +178,17 @@ jobs: # Job 4: Build Rust linking against the real Chapel library (no Zig stubs). # - # Still allow-fail in Wave 1: the full static link of Chapel runtime - # (`libchpl.a` + dependency archives) into the Zig FFI shared library - # is non-trivial and is tracked as the next milestone after #133 - # (Wave 2). The chapel-build artifact is now `libechidna_chapel.a` - # (static) rather than the previous `.so`, because the apt-shipped - # Chapel only carries the `CHPL_LIB_PIC=none` runtime. Once a PIC - # runtime is available in the CI image we can revert to `--dynamic` - # and flip this job to strict. + # Cargo is the final static consumer: Zig's archive intentionally leaves + # chapel_* references for libechidna_chapel.a + the compiler's runtime. + # No PIC rebuild is needed for this executable link. The existing #416 + # waiver is unchanged; promotion requires the exact-source hosted result. rust-chapel-real: name: Rust Build — Real Chapel Library (allow-fail, L2.3+ gate) runs-on: ubuntu-22.04 timeout-minutes: 30 needs: [chapel-build, zig-ffi] + env: + ECHIDNA_CHAPEL_EXPECT_MODE: real continue-on-error: true # allow-fail: issue=#416 expires=2026-11-30 steps: - uses: actions/checkout@v7.0.1 @@ -214,19 +220,33 @@ jobs: uses: actions/download-artifact@v8.0.2 with: name: chapel-lib - path: src/chapel/ + path: src/chapel/lib/ - - name: Build Zig FFI without stubs (links against real Chapel) + - name: Verify real archive exports + run: | + set -euo pipefail + test -s src/chapel/lib/libechidna_chapel.a + nm -g --defined-only src/chapel/lib/libechidna_chapel.a > "$RUNNER_TEMP/chapel-symbols" + awk '{print $NF}' "$RUNNER_TEMP/chapel-symbols" > "$RUNNER_TEMP/chapel-export-names" + for symbol in chapel_is_available chapel_parallel_search chapel_free_result \ + chapel_get_version chapel_available_prover_count chapel_is_prover_available \ + chpl__init_parallel_proof_search chpl__init_chapel_ffi_exports; do + grep -Fxq "$symbol" "$RUNNER_TEMP/chapel-export-names" + done + + - name: Check native link contract controls + run: | + set -euo pipefail + rustc --edition=2021 --test build_support/chapel.rs -o "$RUNNER_TEMP/chapel-link-controls" + "$RUNNER_TEMP/chapel-link-controls" + + - name: Build real static Zig bridge (Chapel linked by Cargo) run: | cd src/zig_ffi zig build -Doptimize=ReleaseSafe -Dcpu=baseline -Dstubs=false - name: Build Rust with real Chapel library - run: | - export LD_LIBRARY_PATH="$PWD/src/chapel:$PWD/src/zig_ffi/zig-out/lib:${LD_LIBRARY_PATH:-}" - cargo build --features chapel + run: cargo build --features chapel - name: Test Rust with real Chapel library - run: | - export LD_LIBRARY_PATH="$PWD/src/chapel:$PWD/src/zig_ffi/zig-out/lib:${LD_LIBRARY_PATH:-}" - cargo test --features chapel -- proof_search + run: cargo test --features chapel -- proof_search diff --git a/.machine_readable/descriptiles/STATE.a2ml b/.machine_readable/descriptiles/STATE.a2ml index 6aaa0696..f0acce57 100644 --- a/.machine_readable/descriptiles/STATE.a2ml +++ b/.machine_readable/descriptiles/STATE.a2ml @@ -1412,3 +1412,15 @@ commits = [ "f73ee00", "46a7408", "6019860", "3828056", "d05dfff", "4a0b068", "c61140c", "85c1b8c", "cb8caff", ] + +[session-2026-11-04-chapel-native-link-candidate] +status = "source candidate; exact-source hosted build and runtime qualification pending" +observed-job = "https://github.com/hyperpolymath/echidna/actions/runs/41921084840/job/126896916928" +observed-failure = "Real Zig bridge omitted all six Chapel export definitions at the final Rust link; build.rs supplied neither the Chapel archive nor runtime dependency closure." +changes = [ + "Separate explicit real and stub bridge modes; real mode links the archive plus official compiler-derived runtime libraries.", + "Initialize the real Chapel runtime and generated modules once before calls; finalize at process exit.", + "Align artifact consumer directory with the local build; check exports and retain the existing job names and waiver expiry.", + "Author native-link refusal controls and real runtime/version/no-prover allocation/free tests; no local execution receipt claimed.", +] +qualification-needed = "Pinned Chapel 2.8.0/Zig 0.14.0 hosted archive, final Rust link, stub controls, real FFI runtime tests, normal formatting and workflow checks." diff --git a/Containerfile b/Containerfile index 035c04db..16e7a64c 100644 --- a/Containerfile +++ b/Containerfile @@ -23,7 +23,8 @@ RUN apk add --no-cache \ WORKDIR /build -COPY Cargo.toml Cargo.lock ./ +COPY Cargo.toml Cargo.lock build.rs ./ +COPY build_support ./build_support COPY src/rust ./src/rust COPY src/interfaces ./src/interfaces # The root `echidna` package is a Cargo workspace whose members include diff --git a/Justfile b/Justfile index 20d04afd..ba46d439 100644 --- a/Justfile +++ b/Justfile @@ -737,9 +737,17 @@ build-chapel-ffi: cd src/zig_ffi && zig build -Doptimize=ReleaseSafe @echo "Library built at src/zig_ffi/zig-out/lib/" -# Build with Chapel support enabled (requires Zig FFI built first) +# Build the feature against explicit unavailable stubs. build-chapel: build-chapel-ffi - cargo build --features chapel + ECHIDNA_CHAPEL_EXPECT_MODE=stubs cargo build --features chapel + +# Build the real static bridge and let Cargo consume Chapel's runtime closure. +build-chapel-real: chapel-build + cd src/zig_ffi && zig build -Doptimize=ReleaseSafe -Dstubs=false + ECHIDNA_CHAPEL_EXPECT_MODE=real cargo build --features chapel + +chapel-test-real: build-chapel-real + ECHIDNA_CHAPEL_EXPECT_MODE=real cargo test --features chapel --lib -- proof_search verify_proof_parallel # Alias retained for muscle-memory; same as chapel-test. test-chapel: chapel-test @@ -748,9 +756,9 @@ test-chapel: chapel-test test-chapel-ffi: cd src/zig_ffi && zig build test -# Full Chapel build + test -chapel-all: chapel-build chapel-smoke build-chapel-ffi chapel-test - @echo "Chapel accelerator fully built and tested" +# Full local real-library path, with the independent stub bridge unit tests. +chapel-all: chapel-smoke test-chapel-ffi chapel-test-real + @echo "Real Chapel build and selected Rust/FFI tests completed" # ── SPARK axiom-policy layer ───────────────────────────────── diff --git a/build.rs b/build.rs index 03faad13..1c44a532 100644 --- a/build.rs +++ b/build.rs @@ -9,7 +9,13 @@ //! 2. cd src/zig && zig build -Dspark //! 3. cargo build --features spark +#[cfg(feature = "chapel")] +#[path = "build_support/chapel.rs"] +mod chapel_link; + fn main() { + println!("cargo:rustc-check-cfg=cfg(echidna_chapel_real)"); + println!("cargo:rustc-check-cfg=cfg(echidna_chapel_stubs)"); // SPARK axiom-policy bridge (requires GPRbuild + Zig; see just spark-all) #[cfg(feature = "spark")] { @@ -90,42 +96,14 @@ fn main() { println!("cargo:rerun-if-changed=src/rust/coprocessor/flint.rs"); } - // Chapel parallel proof search bridge + // Chapel parallel proof search bridge. Real mode also links the compiler's + // runtime closure; a static Zig archive does not supply transitive links. #[cfg(feature = "chapel")] { - // Look for the Zig-built library in src/zig_ffi/zig-out/lib/ - let zig_ffi_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("src") - .join("zig_ffi") - .join("zig-out") - .join("lib"); - - if zig_ffi_dir.exists() { - println!("cargo:rustc-link-search=native={}", zig_ffi_dir.display()); - println!("cargo:rustc-link-lib=static=echidna_chapel_ffi"); - println!("cargo:rustc-link-lib=dylib=c"); - } else { - // Also check standard install location - let alt_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("lib"); - if alt_dir.exists() { - println!("cargo:rustc-link-search=native={}", alt_dir.display()); - println!("cargo:rustc-link-lib=static=echidna_chapel_ffi"); - println!("cargo:rustc-link-lib=dylib=c"); - } else { - println!("cargo:warning=Chapel FFI library not found. Build it first with: just build-chapel-ffi"); - println!("cargo:warning=Looked in: {}", zig_ffi_dir.display()); - } - } - - // Re-run if FFI source changes + chapel_link::configure(std::path::Path::new(env!("CARGO_MANIFEST_DIR"))) + .unwrap_or_else(|error| panic!("Chapel native link setup: {error}")); + println!("cargo:rerun-if-changed=build_support/chapel.rs"); println!("cargo:rerun-if-changed=src/zig_ffi/chapel_bridge.zig"); println!("cargo:rerun-if-changed=src/zig_ffi/build.zig"); - // Re-run when the *built artifact* appears or changes. Without this, - // a `cargo build` performed before `just build-chapel-ffi` caches the - // "library not found" decision (no link directives emitted) and a - // later Zig build is never picked up — link fails with undefined - // `echidna_chapel_*` symbols regardless of recipe order. - println!("cargo:rerun-if-changed=src/zig_ffi/zig-out/lib/libechidna_chapel_ffi.a"); - println!("cargo:rerun-if-changed=src/zig_ffi/zig-out/lib"); } } diff --git a/build_support/chapel.rs b/build_support/chapel.rs new file mode 100644 index 00000000..9f00a639 --- /dev/null +++ b/build_support/chapel.rs @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +//! Native Linux link contract for the optional Chapel bridge. +//! +//! The bridge archive does not contain the Chapel runtime. Real mode obtains +//! its dependency closure from the installed compiler's official compileline +//! helper; unsupported flags and failed tools are errors, never empty success. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Stubs, + Real, +} + +pub fn parse_mode(value: &str) -> Result { + match value { + "stubs" | "stubs\n" => Ok(Mode::Stubs), + "real" | "real\n" => Ok(Mode::Real), + _ => Err("unknown Chapel artifact mode; rebuild the Zig bridge".into()), + } +} + +fn require_mode(actual: Mode, expected: Option<&str>) -> Result<(), String> { + if let Some(expected) = expected { + if actual != parse_mode(expected)? { + return Err("Chapel artifact mode differs from ECHIDNA_CHAPEL_EXPECT_MODE".into()); + } + } + Ok(()) +} + +#[derive(Debug, PartialEq, Eq)] +pub enum LinkFlag { + Search(String), + Library(String), + Rpath(String), +} + +pub fn parse_runtime_flags(output: &str) -> Result, String> { + let output = output.trim(); + if output.is_empty() + || output.chars().any(|c| matches!(c, '\n' | '\r' | '\0' | '\'' | '"' | '\\')) + { + return Err("empty, multiline, or quoted Chapel runtime flags are unsupported".into()); + } + + let mut flags = Vec::new(); + let mut has_runtime = false; + for flag in output.split_ascii_whitespace() { + if let Some(path) = flag.strip_prefix("-L") { + if !Path::new(path).is_absolute() { + return Err(format!("Chapel library search path must be absolute: {flag}")); + } + flags.push(LinkFlag::Search(path.into())); + } else if let Some(name) = flag.strip_prefix("-l") { + if name.is_empty() + || !name.bytes().all(|c| c.is_ascii_alphanumeric() || b"_+.-".contains(&c)) + { + return Err(format!("unsupported Chapel library name: {flag}")); + } + has_runtime |= name == "chpl"; + flags.push(LinkFlag::Library(name.into())); + } else if let Some(path) = flag.strip_prefix("-Wl,-rpath,") { + if !Path::new(path).is_absolute() { + return Err(format!("Chapel runtime rpath must be absolute: {flag}")); + } + flags.push(LinkFlag::Rpath(flag.into())); + } else { + return Err(format!("unsupported Chapel runtime link flag: {flag}")); + } + } + if !has_runtime || !flags.iter().any(|flag| matches!(flag, LinkFlag::Search(_))) { + return Err("Chapel compileline omitted its runtime library or search directory".into()); + } + Ok(flags) +} + +fn command_text(program: &Path, arguments: &[&str]) -> Result { + let output = Command::new(program) + .args(arguments) + .output() + .map_err(|error| format!("cannot execute {}: {error}", program.display()))?; + if !output.status.success() { + return Err(format!( + "{} failed with {}: {}", + program.display(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let value = String::from_utf8(output.stdout) + .map_err(|_| format!("{} returned non-UTF-8 output", program.display()))?; + if value.trim().is_empty() { + return Err(format!("{} returned no output", program.display())); + } + Ok(value) +} + +fn display_path(path: &Path) -> Result<&str, String> { + let value = path.to_str().ok_or("Chapel path is not UTF-8")?; + if value.chars().any(|c| matches!(c, '\n' | '\r' | '\0')) { + return Err("Chapel path contains a line separator".into()); + } + Ok(value) +} + +pub fn configure(root: &Path) -> Result<(), String> { + for variable in [ + "ECHIDNA_CHAPEL_EXPECT_MODE", + "ECHIDNA_CHAPEL_LIB_DIR", + "PATH", + "CHPL_HOME", + "CHPL_TARGET_COMPILER", + "CHPL_TARGET_CPU", + "CHPL_COMM", + "CHPL_TASKS", + "CHPL_LOCALE_MODEL", + "CHPL_LIB_PIC", + "CHPL_MEM", + "CHPL_GMP", + "CHPL_HWLOC", + "CHPL_RE2", + "CHPL_UNWIND", + ] { + println!("cargo:rerun-if-env-changed={variable}"); + } + + let expected = match std::env::var("ECHIDNA_CHAPEL_EXPECT_MODE") { + Ok(value) => Some(value), + Err(std::env::VarError::NotPresent) => None, + Err(error) => return Err(format!("invalid requested Chapel mode: {error}")), + }; + if let Some(expected) = expected.as_deref() { + parse_mode(expected)?; + } + + let candidates = [root.join("src/zig_ffi/zig-out/lib"), root.join("lib")]; + for path in &candidates { + println!("cargo:rerun-if-changed={}", display_path(path)?); + for filename in ["libechidna_chapel_ffi.a", "echidna_chapel_ffi.mode"] { + println!("cargo:rerun-if-changed={}", display_path(&path.join(filename))?); + } + } + + let bridge = match candidates.iter().find(|path| path.join("libechidna_chapel_ffi.a").is_file()) { + Some(path) => path, + None if expected.is_none() => { + // Keep metadata-only cargo check/clippy usable without optional SDKs. + // A linked binary still cannot succeed without the bridge symbols. + println!("cargo:warning=Chapel bridge absent; build with just build-chapel-ffi"); + return Ok(()); + }, + None => return Err("requested Chapel mode has no bridge archive".into()), + }; + + let mode_path = bridge.join("echidna_chapel_ffi.mode"); + let mode_text = std::fs::read_to_string(&mode_path) + .map_err(|error| format!("read {}: {error}; rebuild the Zig bridge", mode_path.display()))?; + let mode = parse_mode(&mode_text)?; + require_mode(mode, expected.as_deref())?; + + println!("cargo:rustc-link-search=native={}", display_path(bridge)?); + println!("cargo:rustc-link-lib=static=echidna_chapel_ffi"); + println!("cargo:rustc-link-lib=dylib=c"); + if mode == Mode::Stubs { + println!("cargo:rustc-cfg=echidna_chapel_stubs"); + return Ok(()); + } + + let host = std::env::var("HOST").map_err(|error| format!("Cargo HOST unavailable: {error}"))?; + let target = std::env::var("TARGET").map_err(|error| format!("Cargo TARGET unavailable: {error}"))?; + if host != target || std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux") + { + return Err("real Chapel linkage currently requires a native Linux target".into()); + } + + let library_dir = std::env::var_os("ECHIDNA_CHAPEL_LIB_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| root.join("src/chapel/lib")) + .canonicalize() + .map_err(|error| format!("real Chapel library directory is unavailable: {error}"))?; + let archive = library_dir.join("libechidna_chapel.a"); + if !archive.is_file() { + return Err(format!("missing real Chapel archive: {}", archive.display())); + } + println!("cargo:rerun-if-changed={}", display_path(&archive)?); + + let home_text = command_text(Path::new("chpl"), &["--print-chpl-home"])?; + let home = Path::new(home_text.trim()); + if !home.is_absolute() { + return Err("chpl --print-chpl-home did not return an absolute directory".into()); + } + let compileline = home.join("util/config/compileline"); + println!("cargo:rerun-if-changed={}", display_path(&compileline)?); + let flags = parse_runtime_flags(&command_text(&compileline, &["--libraries"])?)?; + let runtime_exists = flags.iter().any(|flag| match flag { + LinkFlag::Search(path) => Path::new(path).join("libchpl.a").is_file(), + _ => false, + }); + if !runtime_exists { + return Err("compiler-selected static libchpl.a is unavailable".into()); + } + + println!("cargo:rustc-link-search=native={}", display_path(&library_dir)?); + println!("cargo:rustc-link-lib=static=echidna_chapel"); + for flag in flags { + match flag { + LinkFlag::Search(path) => { + println!("cargo:rerun-if-changed={path}"); + println!("cargo:rustc-link-search=native={path}"); + }, + LinkFlag::Library(name) if name == "chpl" => { + println!("cargo:rustc-link-lib=static=chpl"); + }, + LinkFlag::Library(name) => println!("cargo:rustc-link-lib={name}"), + LinkFlag::Rpath(flag) => println!("cargo:rustc-link-arg={flag}"), + } + } + println!("cargo:rustc-cfg=echidna_chapel_real"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn separates_real_and_stub_modes() { + assert_eq!(parse_mode("real\n"), Ok(Mode::Real)); + assert_eq!(parse_mode("stubs\n"), Ok(Mode::Stubs)); + assert!(require_mode(Mode::Real, Some("real")).is_ok()); + assert!(require_mode(Mode::Stubs, Some("real")).is_err()); + assert!(require_mode(Mode::Real, Some("stubs")).is_err()); + for invalid in ["", "REAL", "real\nstubs", " real", "stub"] { + assert!(parse_mode(invalid).is_err(), "{invalid:?}"); + } + } + + #[test] + fn retains_compiler_library_order_and_paths() { + assert_eq!( + parse_runtime_flags("-L/opt/chapel/lib -lchpl -L/opt/qthread/lib -lqthread -lm -lpthread -ldl\n"), + Ok(vec![ + LinkFlag::Search("/opt/chapel/lib".into()), + LinkFlag::Library("chpl".into()), + LinkFlag::Search("/opt/qthread/lib".into()), + LinkFlag::Library("qthread".into()), + LinkFlag::Library("m".into()), + LinkFlag::Library("pthread".into()), + LinkFlag::Library("dl".into()), + ]) + ); + } + + #[test] + fn accepts_an_absolute_runtime_rpath_without_dropping_it() { + let flags = parse_runtime_flags("-L/opt/lib -lchpl -Wl,-rpath,/opt/lib").unwrap(); + assert_eq!(flags.last(), Some(&LinkFlag::Rpath("-Wl,-rpath,/opt/lib".into()))); + } + + #[test] + fn refuses_missing_runtime_unknown_flags_and_ambiguous_paths() { + for invalid in [ + "", + "-L/opt/lib -lm", + "-lchpl", + "-Lrelative -lchpl", + "-L/opt/lib -lchpl -unknown", + "-L'/path with spaces' -lchpl", + "-L/opt/lib -lchpl\n-lm", + "-L/opt/lib -lchpl -Wl,--start-group", + "-L/opt/lib -lchpl -Wl,-rpath,relative", + "-L/opt/lib -lchpl -l", + ] { + assert!(parse_runtime_flags(invalid).is_err(), "{invalid:?}"); + } + } + + #[test] + fn actual_tool_failure_cannot_promote_valid_looking_stdout() { + let shell = Path::new("sh"); + assert!(command_text(shell, &["-c", "printf '%s\\n' '-L/opt/lib -lchpl'; exit 2"]).is_err()); + assert!(command_text(shell, &["-c", "exit 0"]).is_err()); + assert!(command_text(Path::new("/definitely-missing-chapel-tool"), &[]).is_err()); + let output = command_text(shell, &["-c", "printf '%s\\n' '-L/opt/lib -lchpl'"]).unwrap(); + assert!(parse_runtime_flags(&output).is_ok()); + } +} diff --git a/container/Containerfile b/container/Containerfile index ef8f4fa9..b4a2cb58 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -23,11 +23,16 @@ RUN apk add --no-cache \ WORKDIR /build -COPY Cargo.toml Cargo.lock ./ +COPY Cargo.toml Cargo.lock build.rs ./ +COPY build_support ./build_support COPY src/rust ./src/rust COPY src/interfaces ./src/interfaces +# Cargo loads all workspace member manifests and validates explicit bench targets, +# even when only the echidna binary is requested. +COPY crates ./crates +COPY benches ./benches -RUN cargo build --release --bin echidna +RUN cargo build --locked --release --bin echidna # ============================================================================= # Stage 2: Runtime diff --git a/docs/decisions/2026-05-30-chapel-pic-rebuild.adoc b/docs/decisions/2026-05-30-chapel-pic-rebuild.adoc index 7a0b7b86..d1e58cf5 100644 --- a/docs/decisions/2026-05-30-chapel-pic-rebuild.adoc +++ b/docs/decisions/2026-05-30-chapel-pic-rebuild.adoc @@ -55,11 +55,14 @@ Rationale: * The PIC rebuild is a one-shot ~30 min operation per CI runner, which cannot be amortised across PRs without a registry-pushed container image — see follow-up below. -* The static-library form (`+--library --static+`) is correct for the -current Rust-link path; Rust binaries already embed the Chapel runtime -by linking against `+libechidna_chapel.a+`. Switching to `+.so+` linkage -gains separability of the Chapel runtime but adds packaging complexity -(now two artifacts to ship and version). +* The static-library form (`+--library --static+`) is suitable for a +Rust executable. The 2026-11-04 real CI failure showed that the old +`build.rs` linked only the Zig bridge, omitting both the generated Chapel +archive and its runtime closure. The repair consumes the official compiler +link flags and initializes the runtime before exports; its exact-source +hosted test is still required. A PIC rebuild is not a prerequisite for +this static executable link. Shared linkage adds separate packaging and +runtime constraints. * The PIC form is only *required* for L2.5 (multi-locale Chapel dispatch under PGAS), where the runtime needs to be loadable from multiple Chapel-distinct executables. That work is gated on L1 Cap’n Proto, so diff --git a/src/chapel/README.adoc b/src/chapel/README.adoc index 21875476..9d407fb5 100644 --- a/src/chapel/README.adoc +++ b/src/chapel/README.adoc @@ -1,64 +1,69 @@ -== Chapel Production Integration for ECHIDNA +// SPDX-License-Identifier: MPL-2.0 += Chapel integration for ECHIDNA -Parallel proof search using Chapel’s task parallelism, integrated into -the main ECHIDNA codebase. +Parallel proof search implemented in Chapel, with a Zig bridge consumed by +the optional Rust `chapel` feature. -=== Overview +== Current build boundary -This directory contains the Chapel-based parallel proof search -framework, now integrated into the main ECHIDNA codebase. The Chapel -code is used to accelerate proof search by dispatching theorem proving -goals to multiple prover backends concurrently. +The real native link repair requires exact-source hosted qualification. +The stub build deliberately reports Chapel unavailable; it is a separate +fallback test and cannot qualify the real runtime. -=== Integration Status +The final Rust executable links three parts in order: the static Zig bridge, +`libechidna_chapel.a`, and the runtime/dependency flags returned by the installed +Chapel compiler's official `util/config/compileline --libraries` helper. +`LD_LIBRARY_PATH` cannot supply a missing static link. Real mode currently +supports native Linux and refuses unsupported compiler flags instead of +silently discarding them. -* *Status*: Production-ready -* *Location*: `+src/chapel/+` -* *Build System*: Integrated with `+Justfile+` and `+Cargo.toml+` -* *FFI Bridge*: Zig bridge at `+src/zig_ffi/chapel_bridge.zig+` +The bridge initializes the real runtime and both generated module initializers +once before an export is called, then registers finalization at process exit. +The installed `echidna_chapel_ffi.mode` records real versus stub build intent; +tests also assert actual availability, version, and a no-prover failure result. +That metadata is not an attestation of artifact contents. -=== Build Instructions +== Build and checks -==== 1. Install Chapel +Install the same Chapel 2.8.0 and Zig 0.14.0 used by +`../../.github/workflows/chapel-ci.yml`. The workflow pins and verifies the +official Ubuntu 22.04 Chapel package before installation. [source,bash] ---- -# Download Chapel 2.0 -wget https://github.com/chapel-lang/chapel/releases/download/2.0.0/chapel-2.0.0.tar.gz -tar xzf chapel-2.0.0.tar.gz -cd chapel-2.0.0 +# Real static library, bridge, and Rust feature. +just build-chapel-real +just chapel-test-real -# Set environment -source util/setchplenv.bash +# Includes the standalone Chapel smoke target and independent stub tests. +just chapel-all -# Build (takes ~10 minutes) -make +# Deliberate unavailable-stub fallback only. +just build-chapel +just chapel-test ---- -==== 2. Build Chapel Code +The default real library directory is `src/chapel/lib`. +`ECHIDNA_CHAPEL_LIB_DIR` selects another directory containing +`libechidna_chapel.a`; the matching compiler/runtime must be installed. +`ECHIDNA_CHAPEL_EXPECT_MODE=real` refuses a stub artifact or missing bridge; +`stubs` refuses a real artifact. Rebuild the bridge when mode metadata is +missing. Metadata-only Cargo checks retain the existing warning when no +optional bridge was built and no mode was requested. -[source,bash] ----- -cd src/chapel -chpl parallel_proof_search.chpl -o proof_search ----- +The real CI consumer downloads the same-run archive into that directory, +checks its exports, runs the native-link refusal controls, and executes Rust +FFI tests. The empty-PATH child test exercises a real search/result/free cycle +without starting an external prover. It checks failure, never a proof claim. -==== 3. Build Zig FFI Bridge +The existing CI waiver and its expiry are unchanged. A source patch, stub +success, or separate Chapel smoke target does not establish that this complete +native path has passed. -[source,bash] ----- -cd src/zig_ffi -zig build -Dstubs=false ----- - -==== 4. Build Rust with Chapel Feature - -[source,bash] ----- -cargo build --features chapel ----- +=== Historical example output -=== Expected Output +The example below is retained as an illustration, not as a receipt from the +current CI job or a newly measured performance result. .... ╔═══════════════════════════════════════════════════════╗ @@ -103,7 +108,7 @@ Speedup: 1.51x ✓ Parallel search is significantly faster! .... -=== What This Demonstrates +=== Intended behavior illustrated above ==== 1. Parallel Proof Search @@ -113,7 +118,7 @@ Speedup: 1.51x ==== 2. Performance Benefits -* Typical speedup: 1.5x - 12x (depending on prover success rate) +* Historical claimed speedup: 1.5x - 12x; remeasurement is required for current code. * Best case: 12x (all provers take similar time) * Worst case: 1x (first prover succeeds immediately) @@ -158,7 +163,7 @@ communication between Rust and Chapel. [arabic] . *Update Rust Dispatch*: Modify `+src/rust/dispatch.rs+` to use Chapel for parallel proof search when the `+chapel+` feature is enabled. -. *Add CI/CD Support*: Ensure Chapel code is built and tested in CI. +. *Qualify the real-library CI path*: require its actual final link and runtime tests. . *Performance Optimization*: Run benchmarks and tune the Chapel code. . *Documentation*: Update documentation to reflect the production integration. diff --git a/src/interfaces/graphql/tests/startup.rs b/src/interfaces/graphql/tests/startup.rs index 1a08f3e9..002987d1 100644 --- a/src/interfaces/graphql/tests/startup.rs +++ b/src/interfaces/graphql/tests/startup.rs @@ -128,3 +128,42 @@ async fn hostname_override_serves_graphql_at_the_root() { reqwest::StatusCode::NOT_FOUND ); } + +#[tokio::test] +async fn absent_override_starts_the_binary_on_the_default_address() { + // A fixed default is the contract under test. Fail explicitly if another + // service already owns it, rather than probing that service or skipping. + let reservation = + TcpListener::bind("127.0.0.1:8082").expect("default GraphQL test port must be available"); + let mut command = server(); + command.env_remove("ECHIDNA_GRAPHQL_ADDR"); + drop(reservation); + + let mut process = RunningServer(command.spawn().expect("start default GraphQL process")); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(250)) + .build() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + assert!( + process.0.try_wait().unwrap().is_none(), + "default GraphQL process exited before serving health" + ); + if let Ok(response) = client.get("http://127.0.0.1:8082/health").send().await { + if response.status().is_success() && response.text().await.unwrap() == "OK" { + assert!( + process.0.try_wait().unwrap().is_none(), + "default GraphQL process exited while checking health" + ); + return; + } + } + assert!( + Instant::now() < deadline, + "binary without ECHIDNA_GRAPHQL_ADDR did not serve 127.0.0.1:8082" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} diff --git a/src/rust/proof_search.rs b/src/rust/proof_search.rs index de39515c..094ace8e 100644 --- a/src/rust/proof_search.rs +++ b/src/rust/proof_search.rs @@ -220,14 +220,14 @@ mod chapel_ffi { } fn available(&self) -> bool { - // SAFETY: echidna_chapel_is_available is a pure query with no side effects. + // SAFETY: The bridge initializes the real runtime once before querying it. unsafe { echidna_chapel_is_available() != 0 } } } impl ChapelParallelSearch { pub fn new() -> Result { - // SAFETY: echidna_chapel_is_available is a pure query with no side effects. + // SAFETY: The bridge initializes the real runtime once before querying it. if unsafe { echidna_chapel_is_available() } == 0 { anyhow::bail!("Chapel runtime not available"); } @@ -253,7 +253,7 @@ mod chapel_ffi { /// Get count of provers available on this system (0-30) pub fn available_prover_count() -> u32 { - // SAFETY: Pure query, no side effects. + // SAFETY: The bridge initializes the real runtime before querying the registry. let count = unsafe { echidna_chapel_available_prover_count() }; if count >= 0 { count as u32 @@ -262,6 +262,75 @@ mod chapel_ffi { } } } + #[cfg(test)] + mod tests { + use super::*; + + #[cfg(echidna_chapel_stubs)] + #[test] + fn stub_mode_cannot_claim_a_real_runtime() { + assert!(ChapelParallelSearch::new().is_err()); + assert!(ChapelParallelSearch::get_version().is_err()); + assert_eq!(ChapelParallelSearch::available_prover_count(), 0); + } + + #[cfg(echidna_chapel_real)] + #[test] + fn real_runtime_initializes_once_and_reports_its_version() { + for _ in 0..2 { + let strategy = ChapelParallelSearch::new().expect("real Chapel runtime"); + assert!(strategy.available()); + let version = ChapelParallelSearch::get_version().expect("real Chapel version"); + assert!(version.starts_with("ECHIDNA Chapel Metalayer ")); + assert!(ChapelParallelSearch::available_prover_count() <= 30); + } + } + + #[cfg(echidna_chapel_real)] + #[test] + fn real_search_without_provers_fails_and_frees_result() { + const CHILD: &str = "ECHIDNA_CHAPEL_EMPTY_PATH_CHILD"; + if std::env::var_os(CHILD).is_some() { + let strategy = ChapelParallelSearch::new().expect("real Chapel runtime"); + assert_eq!(ChapelParallelSearch::available_prover_count(), 0); + for _ in 0..2 { + let result = strategy + .search("True", Duration::from_secs(1)) + .expect("FFI returns a result when no prover is available"); + assert!(!result.success); + assert_eq!(result.error_message.as_deref(), Some("All provers exhausted")); + } + println!("ECHIDNA_REAL_CHAPEL_NO_PROVER_PROBE_COMPLETED"); + return; + } + + // A fresh process gets an empty PATH before runtime initialization. + // No global environment mutation races with other Rust tests, and + // no external prover can be dispatched by this allocation/free probe. + let empty_path = tempfile::tempdir().expect("empty prover search directory"); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "proof_search::chapel_ffi::tests::real_search_without_provers_fails_and_frees_result", + "--nocapture", + ]) + .env(CHILD, "1") + .env("PATH", empty_path.path()) + .output() + .expect("launch isolated real-runtime probe"); + assert!( + result.status.success(), + "real-runtime child failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert!( + String::from_utf8_lossy(&result.stdout) + .contains("ECHIDNA_REAL_CHAPEL_NO_PROVER_PROBE_COMPLETED"), + "the exact child test did not complete its real-runtime assertions" + ); + } + } + } #[cfg(feature = "chapel")] diff --git a/src/zig_ffi/build.zig b/src/zig_ffi/build.zig index 122934f2..1d185ee4 100644 --- a/src/zig_ffi/build.zig +++ b/src/zig_ffi/build.zig @@ -1,55 +1,42 @@ // SPDX-License-Identifier: MPL-2.0 // ECHIDNA Zig FFI Build Configuration -// Compatible with Zig 0.14+/0.15+ +// Compatible with Zig 0.14 const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const stubs = b.option(bool, "stubs", "Bundle Chapel-unavailable stubs") orelse true; + const options = b.addOptions(); + options.addOption(bool, "chapel_stubs", stubs); - // Whether to bundle chapel_stubs.c into the produced libraries. - // - // Default = true so the resulting static/shared library resolves every - // `chapel_*` symbol on its own — Rust's `cargo build --features chapel` - // then links cleanly without a Chapel install in sight. - // - // Pass `-Dstubs=false` when linking against the real libechidna_chapel - // (built from chapel_poc/); the Chapel-compiled symbols take the place - // of the stubs. - const stubs = b.option(bool, "stubs", "Bundle chapel_stubs.c so chapel_* symbols resolve without Chapel") orelse true; - - // Build shared library for FFI - const lib = b.addLibrary(.{ - .name = "echidna_chapel_ffi", - .root_module = b.createModule(.{ - .root_source_file = b.path("chapel_bridge.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }), - .linkage = .dynamic, - }); - // Bundle Zig's compiler-rt into the artifact. Zig std (panic/stack-trace - // DWARF machinery) references compiler-rt routines such as - // `__zig_probe_stack`; without bundling, linking this library into a - // non-Zig binary (Rust `cargo build --features chapel`) fails with - // `undefined symbol: __zig_probe_stack`. - lib.bundle_compiler_rt = true; - // Chapel C headers are in the same directory - lib.root_module.addIncludePath(b.path(".")); + // A real bridge is a static archive with Chapel references. The final + // consumer links libechidna_chapel.a and the compiler-selected runtime. + // Do not produce a nominal real shared object with unresolved symbols: + // the packaged Chapel runtime is non-PIC. if (stubs) { - // -fno-sanitize=undefined: Zig defaults UBSan on for C, pulling in - // __ubsan_* that Rust's linker cannot resolve. Stubs are trivial; - // skip UBSan. - lib.addCSourceFile(.{ .file = b.path("chapel_stubs.c"), .flags = &.{"-fno-sanitize=undefined"} }); + const shared = b.addLibrary(.{ + .name = "echidna_chapel_ffi", + .root_module = b.createModule(.{ + .root_source_file = b.path("chapel_bridge.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + .linkage = .dynamic, + }); + shared.bundle_compiler_rt = true; + shared.root_module.addIncludePath(b.path(".")); + shared.root_module.addOptions("chapel_options", options); + shared.addCSourceFile(.{ + .file = b.path("chapel_stubs.c"), + .flags = &.{"-fno-sanitize=undefined"}, + }); + b.installArtifact(shared); } - // Install artifacts - b.installArtifact(lib); - - // Static library (alternative) - const lib_static = b.addLibrary(.{ + const archive = b.addLibrary(.{ .name = "echidna_chapel_ffi", .root_module = b.createModule(.{ .root_source_file = b.path("chapel_bridge.zig"), @@ -59,18 +46,29 @@ pub fn build(b: *std.Build) void { }), .linkage = .static, }); - // See note on lib.bundle_compiler_rt above — the static archive is the - // one Rust actually links (`rustc-link-lib=static=echidna_chapel_ffi`), - // so it must carry compiler-rt itself. - lib_static.bundle_compiler_rt = true; - lib_static.root_module.addIncludePath(b.path(".")); + // Rust consumes this archive, so bundle Zig's compiler-rt instead of + // requiring a separate Zig runtime at the final non-Zig link. + archive.bundle_compiler_rt = true; + archive.root_module.addIncludePath(b.path(".")); + archive.root_module.addOptions("chapel_options", options); if (stubs) { - lib_static.addCSourceFile(.{ .file = b.path("chapel_stubs.c"), .flags = &.{"-fno-sanitize=undefined"} }); + archive.addCSourceFile(.{ + .file = b.path("chapel_stubs.c"), + .flags = &.{"-fno-sanitize=undefined"}, + }); } + b.installArtifact(archive); - b.installArtifact(lib_static); + // Install mode metadata next to the archive. Cargo checks an explicitly + // requested mode and real-runtime tests still check actual behavior. + const generated = b.addWriteFiles(); + const mode = generated.add("echidna_chapel_ffi.mode", if (stubs) "stubs\n" else "real\n"); + const install_mode = b.addInstallLibFile(mode, "echidna_chapel_ffi.mode"); + b.getInstallStep().dependOn(&install_mode.step); - // Unit tests (link against stubs when Chapel not available) + // Independent bridge unit tests always exercise unavailable-stub behavior. + const test_options = b.addOptions(); + test_options.addOption(bool, "chapel_stubs", true); const lib_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("chapel_bridge.zig"), @@ -80,10 +78,9 @@ pub fn build(b: *std.Build) void { }), }); lib_tests.root_module.addIncludePath(b.path(".")); + lib_tests.root_module.addOptions("chapel_options", test_options); lib_tests.addCSourceFile(.{ .file = b.path("chapel_stubs.c") }); - const run_lib_tests = b.addRunArtifact(lib_tests); - - const test_step = b.step("test", "Run FFI tests"); + const test_step = b.step("test", "Run FFI tests with explicit Chapel stubs"); test_step.dependOn(&run_lib_tests.step); } diff --git a/src/zig_ffi/chapel_bridge.zig b/src/zig_ffi/chapel_bridge.zig index 10d63473..c9c8bd33 100644 --- a/src/zig_ffi/chapel_bridge.zig +++ b/src/zig_ffi/chapel_bridge.zig @@ -10,6 +10,7 @@ // Chapel (C API) → Zig (@cImport) → Rust (safe calls) const std = @import("std"); +const options = @import("chapel_options"); //============================================================================== // Import Chapel's C API @@ -17,8 +18,41 @@ const std = @import("std"); const chapel = @cImport({ @cInclude("chapel_ffi_exports.h"); + @cInclude("stdlib.h"); }); +// Chapel 2.8's library ABI requires runtime + exported module initialization +// before any library call. The once guard is shared by every bridge entry; +// stub builds do not reference or initialize a Chapel runtime. +const ChapelRuntime = struct { + var program_name = [_:0]u8{ 'e', 'c', 'h', 'i', 'd', 'n', 'a' }; + var arguments = [_]?[*:0]u8{ &program_name, null }; + + extern fn chpl_library_init(argc: c_int, argv: [*]?[*:0]u8) void; + extern fn chpl_library_finalize() void; + extern fn chpl__init_parallel_proof_search(line: i64, file: i32) void; + extern fn chpl__init_chapel_ffi_exports(line: i64, file: i32) void; + + fn initialize() void { + chpl_library_init(1, &arguments); + chpl__init_parallel_proof_search(0, 0); + chpl__init_chapel_ffi_exports(0, 0); + if (chapel.atexit(finalize) != 0) { + @panic("could not register Chapel runtime finalization"); + } + } + + fn finalize() callconv(.c) void { + chpl_library_finalize(); + } +}; + +var chapel_runtime_once = std.once(ChapelRuntime.initialize); + +fn ensureChapelInitialized() void { + if (!options.chapel_stubs) chapel_runtime_once.call(); +} + //============================================================================== // Core Types — all 30 prover backends //============================================================================== @@ -206,12 +240,14 @@ pub const ChapelError = error{ /// Check if Chapel runtime is available pub fn isChapelAvailable() bool { + ensureChapelInitialized(); const result = chapel.chapel_is_available(); return result != 0; } /// Get Chapel metalayer version string pub fn getChapelVersion(allocator: std.mem.Allocator) ![]const u8 { + ensureChapelInitialized(); const c_version = chapel.chapel_get_version(); if (c_version == null) { return error.ChapelRuntimeUnavailable; @@ -223,12 +259,14 @@ pub fn getChapelVersion(allocator: std.mem.Allocator) ![]const u8 { /// Get count of provers available on this system (0-30) pub fn availableProverCount() u32 { + ensureChapelInitialized(); const count = chapel.chapel_available_prover_count(); return if (count >= 0) @intCast(count) else 0; } /// Check if a specific prover is available pub fn isProverAvailable(prover: ProverKind) bool { + ensureChapelInitialized(); return chapel.chapel_is_prover_available(@intFromEnum(prover)) != 0; } From f160493cc1e0331d649b30cf94b3d03c40773b1e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:57:32 +0000 Subject: [PATCH 26/30] fix(ci): respect Chapel compiler layout and prover exit status --- .github/workflows/chapel-ci.yml | 8 +- Justfile | 8 +- build_support/chapel.rs | 259 +++++++++++++++++++++++++++--- container/Containerfile | 2 +- docs/ARCHITECTURE.adoc | 2 +- src/rust/proof_search.rs | 6 +- src/rust/provers/altergo.rs | 3 + src/rust/provers/cvc5.rs | 3 + src/rust/provers/eprover.rs | 3 + src/rust/provers/glpk.rs | 3 + src/rust/provers/minizinc.rs | 3 + src/rust/provers/vampire.rs | 3 + src/rust/provers/version_probe.rs | 54 +++++++ src/rust/provers/z3.rs | 3 + 14 files changed, 325 insertions(+), 35 deletions(-) diff --git a/.github/workflows/chapel-ci.yml b/.github/workflows/chapel-ci.yml index 41d6048e..abfeff2c 100644 --- a/.github/workflows/chapel-ci.yml +++ b/.github/workflows/chapel-ci.yml @@ -171,10 +171,10 @@ jobs: path: src/zig_ffi/zig-out/lib/ - name: Build with chapel feature - run: cargo build --features chapel + run: cargo build --locked --features chapel - name: Test with chapel feature - run: cargo test --features chapel -- proof_search + run: cargo test --locked --features chapel -- proof_search # Job 4: Build Rust linking against the real Chapel library (no Zig stubs). # @@ -246,7 +246,7 @@ jobs: zig build -Doptimize=ReleaseSafe -Dcpu=baseline -Dstubs=false - name: Build Rust with real Chapel library - run: cargo build --features chapel + run: cargo build --locked --features chapel - name: Test Rust with real Chapel library - run: cargo test --features chapel -- proof_search + run: cargo test --locked --features chapel -- proof_search diff --git a/Justfile b/Justfile index ba46d439..3f11a86f 100644 --- a/Justfile +++ b/Justfile @@ -648,7 +648,7 @@ chapel-smoke: # Run the Chapel-gated Rust tests (`--features chapel`). chapel-test: build-chapel-ffi - cargo test --features chapel --lib -- proof_search verify_proof_parallel + cargo test --locked --features chapel --lib -- proof_search verify_proof_parallel # Speedup baseline: compares sequentialProofSearch / # parallelProofSearch / parallelProofSearchSpeculative against the @@ -739,15 +739,15 @@ build-chapel-ffi: # Build the feature against explicit unavailable stubs. build-chapel: build-chapel-ffi - ECHIDNA_CHAPEL_EXPECT_MODE=stubs cargo build --features chapel + ECHIDNA_CHAPEL_EXPECT_MODE=stubs cargo build --locked --features chapel # Build the real static bridge and let Cargo consume Chapel's runtime closure. build-chapel-real: chapel-build cd src/zig_ffi && zig build -Doptimize=ReleaseSafe -Dstubs=false - ECHIDNA_CHAPEL_EXPECT_MODE=real cargo build --features chapel + ECHIDNA_CHAPEL_EXPECT_MODE=real cargo build --locked --features chapel chapel-test-real: build-chapel-real - ECHIDNA_CHAPEL_EXPECT_MODE=real cargo test --features chapel --lib -- proof_search verify_proof_parallel + ECHIDNA_CHAPEL_EXPECT_MODE=real cargo test --locked --features chapel --lib -- proof_search verify_proof_parallel # Alias retained for muscle-memory; same as chapel-test. test-chapel: chapel-test diff --git a/build_support/chapel.rs b/build_support/chapel.rs index 9f00a639..a8a1e359 100644 --- a/build_support/chapel.rs +++ b/build_support/chapel.rs @@ -42,7 +42,9 @@ pub enum LinkFlag { pub fn parse_runtime_flags(output: &str) -> Result, String> { let output = output.trim(); if output.is_empty() - || output.chars().any(|c| matches!(c, '\n' | '\r' | '\0' | '\'' | '"' | '\\')) + || output + .chars() + .any(|c| matches!(c, '\n' | '\r' | '\0' | '\'' | '"' | '\\')) { return Err("empty, multiline, or quoted Chapel runtime flags are unsupported".into()); } @@ -52,12 +54,16 @@ pub fn parse_runtime_flags(output: &str) -> Result, String> { for flag in output.split_ascii_whitespace() { if let Some(path) = flag.strip_prefix("-L") { if !Path::new(path).is_absolute() { - return Err(format!("Chapel library search path must be absolute: {flag}")); + return Err(format!( + "Chapel library search path must be absolute: {flag}" + )); } flags.push(LinkFlag::Search(path.into())); } else if let Some(name) = flag.strip_prefix("-l") { if name.is_empty() - || !name.bytes().all(|c| c.is_ascii_alphanumeric() || b"_+.-".contains(&c)) + || !name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_+.-".contains(&c)) { return Err(format!("unsupported Chapel library name: {flag}")); } @@ -78,9 +84,75 @@ pub fn parse_runtime_flags(output: &str) -> Result, String> { Ok(flags) } +/// The installed compiler owns these paths; prefix installs do not keep their +/// runtime and third-party files beneath CHPL_HOME. +#[derive(Debug, PartialEq, Eq)] +struct CompilerLayout { + home: PathBuf, + runtime_lib: PathBuf, + runtime_incl: PathBuf, + third_party: PathBuf, +} + +fn layout_path(line: Option<&str>, key: &str) -> Result { + let prefix = format!("{key}: "); + let value = line + .and_then(|line| line.strip_prefix(prefix.as_str())) + .ok_or_else(|| format!("compiler settings omitted or reordered {key}"))?; + if !Path::new(value).is_absolute() || value.chars().any(char::is_control) { + return Err(format!( + "compiler setting {key} is not an absolute ordinary path" + )); + } + Ok(PathBuf::from(value)) +} + +fn parse_compiler_layout(output: &str) -> Result { + // Chapel 2.8 prints this ordered header, then a blank line and printchplenv. + // The later human-readable settings repeat CHPL_HOME with origin markers. + let mut lines = output.lines(); + let layout = CompilerLayout { + home: layout_path(lines.next(), "CHPL_HOME")?, + runtime_lib: layout_path(lines.next(), "CHPL_RUNTIME_LIB")?, + runtime_incl: layout_path(lines.next(), "CHPL_RUNTIME_INCL")?, + third_party: layout_path(lines.next(), "CHPL_THIRD_PARTY")?, + }; + if lines.next() != Some("") { + return Err("compiler settings header has no blank separator".into()); + } + Ok(layout) +} + +fn compiler_runtime_flags(layout: &CompilerLayout) -> Result, String> { + let environment = [ + ("CHPL_HOME", layout.home.as_path()), + ("CHPL_RUNTIME_LIB", layout.runtime_lib.as_path()), + ("CHPL_RUNTIME_INCL", layout.runtime_incl.as_path()), + ("CHPL_THIRD_PARTY", layout.third_party.as_path()), + ]; + for (key, path) in &environment { + if !path.is_dir() { + return Err(format!("compiler-selected {key} directory is unavailable")); + } + } + let compileline = layout.home.join("util/config/compileline"); + println!("cargo:rerun-if-changed={}", display_path(&compileline)?); + let output = command_text_with_env(&compileline, &["--libraries"], &environment)?; + parse_runtime_flags(&output) +} + fn command_text(program: &Path, arguments: &[&str]) -> Result { + command_text_with_env(program, arguments, &[]) +} + +fn command_text_with_env( + program: &Path, + arguments: &[&str], + environment: &[(&str, &Path)], +) -> Result { let output = Command::new(program) .args(arguments) + .envs(environment.iter().copied()) .output() .map_err(|error| format!("cannot execute {}: {error}", program.display()))?; if !output.status.success() { @@ -113,6 +185,9 @@ pub fn configure(root: &Path) -> Result<(), String> { "ECHIDNA_CHAPEL_LIB_DIR", "PATH", "CHPL_HOME", + "CHPL_RUNTIME_LIB", + "CHPL_RUNTIME_INCL", + "CHPL_THIRD_PARTY", "CHPL_TARGET_COMPILER", "CHPL_TARGET_CPU", "CHPL_COMM", @@ -141,11 +216,17 @@ pub fn configure(root: &Path) -> Result<(), String> { for path in &candidates { println!("cargo:rerun-if-changed={}", display_path(path)?); for filename in ["libechidna_chapel_ffi.a", "echidna_chapel_ffi.mode"] { - println!("cargo:rerun-if-changed={}", display_path(&path.join(filename))?); + println!( + "cargo:rerun-if-changed={}", + display_path(&path.join(filename))? + ); } } - let bridge = match candidates.iter().find(|path| path.join("libechidna_chapel_ffi.a").is_file()) { + let bridge = match candidates + .iter() + .find(|path| path.join("libechidna_chapel_ffi.a").is_file()) + { Some(path) => path, None if expected.is_none() => { // Keep metadata-only cargo check/clippy usable without optional SDKs. @@ -157,8 +238,12 @@ pub fn configure(root: &Path) -> Result<(), String> { }; let mode_path = bridge.join("echidna_chapel_ffi.mode"); - let mode_text = std::fs::read_to_string(&mode_path) - .map_err(|error| format!("read {}: {error}; rebuild the Zig bridge", mode_path.display()))?; + let mode_text = std::fs::read_to_string(&mode_path).map_err(|error| { + format!( + "read {}: {error}; rebuild the Zig bridge", + mode_path.display() + ) + })?; let mode = parse_mode(&mode_text)?; require_mode(mode, expected.as_deref())?; @@ -171,9 +256,9 @@ pub fn configure(root: &Path) -> Result<(), String> { } let host = std::env::var("HOST").map_err(|error| format!("Cargo HOST unavailable: {error}"))?; - let target = std::env::var("TARGET").map_err(|error| format!("Cargo TARGET unavailable: {error}"))?; - if host != target || std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux") - { + let target = + std::env::var("TARGET").map_err(|error| format!("Cargo TARGET unavailable: {error}"))?; + if host != target || std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux") { return Err("real Chapel linkage currently requires a native Linux target".into()); } @@ -184,18 +269,16 @@ pub fn configure(root: &Path) -> Result<(), String> { .map_err(|error| format!("real Chapel library directory is unavailable: {error}"))?; let archive = library_dir.join("libechidna_chapel.a"); if !archive.is_file() { - return Err(format!("missing real Chapel archive: {}", archive.display())); + return Err(format!( + "missing real Chapel archive: {}", + archive.display() + )); } println!("cargo:rerun-if-changed={}", display_path(&archive)?); - let home_text = command_text(Path::new("chpl"), &["--print-chpl-home"])?; - let home = Path::new(home_text.trim()); - if !home.is_absolute() { - return Err("chpl --print-chpl-home did not return an absolute directory".into()); - } - let compileline = home.join("util/config/compileline"); - println!("cargo:rerun-if-changed={}", display_path(&compileline)?); - let flags = parse_runtime_flags(&command_text(&compileline, &["--libraries"])?)?; + let settings = command_text(Path::new("chpl"), &["--print-chpl-settings"])?; + let layout = parse_compiler_layout(&settings)?; + let flags = compiler_runtime_flags(&layout)?; let runtime_exists = flags.iter().any(|flag| match flag { LinkFlag::Search(path) => Path::new(path).join("libchpl.a").is_file(), _ => false, @@ -204,7 +287,10 @@ pub fn configure(root: &Path) -> Result<(), String> { return Err("compiler-selected static libchpl.a is unavailable".into()); } - println!("cargo:rustc-link-search=native={}", display_path(&library_dir)?); + println!( + "cargo:rustc-link-search=native={}", + display_path(&library_dir)? + ); println!("cargo:rustc-link-lib=static=echidna_chapel"); for flag in flags { match flag { @@ -227,6 +313,126 @@ pub fn configure(root: &Path) -> Result<(), String> { mod tests { use super::*; + const PREFIX_SETTINGS: &str = concat!( + "CHPL_HOME: /prefix/share/chapel/2.8\n", + "CHPL_RUNTIME_LIB: /prefix/lib/chapel/2.8/runtime/lib\n", + "CHPL_RUNTIME_INCL: /prefix/lib/chapel/2.8/runtime/include\n", + "CHPL_THIRD_PARTY: /prefix/lib/chapel/2.8/third-party\n", + "\n", + "machine info: fixture\n", + "CHPL_HOME: /prefix/share/chapel/2.8 *\n", + ); + + #[test] + fn installed_layout_comes_from_the_compiler_header() { + let layout = parse_compiler_layout(PREFIX_SETTINGS).unwrap(); + assert_eq!(layout.home, Path::new("/prefix/share/chapel/2.8")); + assert_eq!( + layout.runtime_lib, + Path::new("/prefix/lib/chapel/2.8/runtime/lib") + ); + assert_eq!( + layout.runtime_incl, + Path::new("/prefix/lib/chapel/2.8/runtime/include") + ); + assert_eq!( + layout.third_party, + Path::new("/prefix/lib/chapel/2.8/third-party") + ); + } + + #[test] + fn compiler_layout_rejects_incomplete_ambiguous_and_relative_headers() { + let invalid = [ + String::new(), + PREFIX_SETTINGS.replace( + "CHPL_RUNTIME_LIB: /prefix/lib/chapel/2.8/runtime/lib\n", + "", + ), + PREFIX_SETTINGS.replace( + "CHPL_RUNTIME_LIB: /prefix/lib/chapel/2.8/runtime/lib", + "CHPL_RUNTIME_LIB: relative/lib", + ), + PREFIX_SETTINGS.replace("CHPL_RUNTIME_INCL:", "CHPL_RUNTIME_LIB:"), + format!("CHPL_HOME: /different\n{PREFIX_SETTINGS}"), + PREFIX_SETTINGS.replace("\n\n", "\nCHPL_HOME: /different\n\n"), + PREFIX_SETTINGS.replace("\n\n", "\n"), + PREFIX_SETTINGS.replace("/prefix/lib/chapel/2.8/third-party", ""), + PREFIX_SETTINGS.replace("/prefix/lib/chapel/2.8/third-party", "/bad\0path"), + PREFIX_SETTINGS.replace("/prefix/lib/chapel/2.8/third-party", "/bad\tpath"), + ]; + for output in invalid { + assert!(parse_compiler_layout(&output).is_err(), "{output:?}"); + } + } + + #[cfg(unix)] + #[test] + fn actual_helper_receives_every_compiler_selected_directory() { + use std::os::unix::fs::PermissionsExt; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TemporaryDirectory(PathBuf); + impl Drop for TemporaryDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + let root = std::env::temp_dir().join(format!( + "echidna-chapel-layout-{}-{}", + std::process::id(), + unique.as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + let _cleanup = TemporaryDirectory(root.clone()); + let layout = CompilerLayout { + home: root.join("home"), + runtime_lib: root.join("runtime-lib"), + runtime_incl: root.join("runtime-include"), + third_party: root.join("third-party"), + }; + for path in [ + &layout.home, + &layout.runtime_lib, + &layout.runtime_incl, + &layout.third_party, + ] { + std::fs::create_dir(path).unwrap(); + } + std::fs::create_dir_all(layout.home.join("util/config")).unwrap(); + let helper = layout.home.join("util/config/compileline"); + let script = concat!( + "#!/bin/sh\n", + "[ \"$#\" -eq 1 ] && [ \"$1\" = --libraries ] || exit 9\n", + "printf '%s\\n' \"-L$CHPL_RUNTIME_LIB -lchpl ", + "-L$CHPL_RUNTIME_INCL -L$CHPL_THIRD_PARTY -L$CHPL_HOME\"\n", + ); + std::fs::write(&helper, script).unwrap(); + std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + compiler_runtime_flags(&layout).unwrap(), + vec![ + LinkFlag::Search(layout.runtime_lib.to_str().unwrap().into()), + LinkFlag::Library("chpl".into()), + LinkFlag::Search(layout.runtime_incl.to_str().unwrap().into()), + LinkFlag::Search(layout.third_party.to_str().unwrap().into()), + LinkFlag::Search(layout.home.to_str().unwrap().into()), + ] + ); + + for failed_script in [ + "#!/bin/sh\nprintf '%s\\n' '-L/valid-looking -lchpl'; exit 2\n", + "#!/bin/sh\nexit 0\n", + ] { + std::fs::write(&helper, failed_script).unwrap(); + assert!(compiler_runtime_flags(&layout).is_err()); + } + std::fs::remove_file(&helper).unwrap(); + assert!(compiler_runtime_flags(&layout).is_err()); + } + #[test] fn separates_real_and_stub_modes() { assert_eq!(parse_mode("real\n"), Ok(Mode::Real)); @@ -242,7 +448,9 @@ mod tests { #[test] fn retains_compiler_library_order_and_paths() { assert_eq!( - parse_runtime_flags("-L/opt/chapel/lib -lchpl -L/opt/qthread/lib -lqthread -lm -lpthread -ldl\n"), + parse_runtime_flags( + "-L/opt/chapel/lib -lchpl -L/opt/qthread/lib -lqthread -lm -lpthread -ldl\n" + ), Ok(vec![ LinkFlag::Search("/opt/chapel/lib".into()), LinkFlag::Library("chpl".into()), @@ -258,7 +466,10 @@ mod tests { #[test] fn accepts_an_absolute_runtime_rpath_without_dropping_it() { let flags = parse_runtime_flags("-L/opt/lib -lchpl -Wl,-rpath,/opt/lib").unwrap(); - assert_eq!(flags.last(), Some(&LinkFlag::Rpath("-Wl,-rpath,/opt/lib".into()))); + assert_eq!( + flags.last(), + Some(&LinkFlag::Rpath("-Wl,-rpath,/opt/lib".into())) + ); } #[test] @@ -282,7 +493,9 @@ mod tests { #[test] fn actual_tool_failure_cannot_promote_valid_looking_stdout() { let shell = Path::new("sh"); - assert!(command_text(shell, &["-c", "printf '%s\\n' '-L/opt/lib -lchpl'; exit 2"]).is_err()); + assert!( + command_text(shell, &["-c", "printf '%s\\n' '-L/opt/lib -lchpl'; exit 2"]).is_err() + ); assert!(command_text(shell, &["-c", "exit 0"]).is_err()); assert!(command_text(Path::new("/definitely-missing-chapel-tool"), &[]).is_err()); let output = command_text(shell, &["-c", "printf '%s\\n' '-L/opt/lib -lchpl'"]).unwrap(); diff --git a/container/Containerfile b/container/Containerfile index b4a2cb58..bdb5a808 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -80,6 +80,6 @@ USER appuser EXPOSE 8081 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -sf http://localhost:${APP_PORT}/api/health || exit 1 + CMD curl -sf "http://localhost:${APP_PORT}/api/health" || exit 1 ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/docs/ARCHITECTURE.adoc b/docs/ARCHITECTURE.adoc index 46b68609..75b9dcaf 100644 --- a/docs/ARCHITECTURE.adoc +++ b/docs/ARCHITECTURE.adoc @@ -41,7 +41,7 @@ the binary you invoked is the binary you pinned. │ │ │ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ │ │ │ CLI/REPL │ │ REST │ │ gRPC │ │ FFI (Zig) │ │ -│ │ (main.rs)│ │ (axum :8081) │ │ (tonic :50051)│ │ (16 exports) │ │ +│ │ (main.rs)│ │ (axum :8000) │ │ (tonic :50051)│ │ (16 exports) │ │ │ └────┬─────┘ └──────┬────────┘ └──────┬────────┘ └──────┬───────┘ │ │ └────────────────┴──────────────────┴────────────────┘ │ │ │ │ diff --git a/src/rust/proof_search.rs b/src/rust/proof_search.rs index 094ace8e..ddbaab21 100644 --- a/src/rust/proof_search.rs +++ b/src/rust/proof_search.rs @@ -298,7 +298,10 @@ mod chapel_ffi { .search("True", Duration::from_secs(1)) .expect("FFI returns a result when no prover is available"); assert!(!result.success); - assert_eq!(result.error_message.as_deref(), Some("All provers exhausted")); + assert_eq!( + result.error_message.as_deref(), + Some("All provers exhausted") + ); } println!("ECHIDNA_REAL_CHAPEL_NO_PROVER_PROBE_COMPLETED"); return; @@ -330,7 +333,6 @@ mod chapel_ffi { ); } } - } #[cfg(feature = "chapel")] diff --git a/src/rust/provers/altergo.rs b/src/rust/provers/altergo.rs index 2b2014df..9dc224f9 100644 --- a/src/rust/provers/altergo.rs +++ b/src/rust/provers/altergo.rs @@ -99,6 +99,9 @@ impl ProverBackend for AltErgoBackend { let output = super::version_probe::run(&self.config, &["--version"]) .await .context("Failed to run alt-ergo --version")?; + if !output.status.success() { + anyhow::bail!("Alt-Ergo version probe failed: {}", output.status); + } let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout diff --git a/src/rust/provers/cvc5.rs b/src/rust/provers/cvc5.rs index 39297a6f..11c19608 100644 --- a/src/rust/provers/cvc5.rs +++ b/src/rust/provers/cvc5.rs @@ -537,6 +537,9 @@ impl ProverBackend for CVC5Backend { let output = super::version_probe::run(&self.config.base, &["--version"]) .await .context("Failed to execute CVC5")?; + if !output.status.success() { + anyhow::bail!("CVC5 version probe failed: {}", output.status); + } let version_str = String::from_utf8_lossy(&output.stdout); Ok(version_str.lines().next().unwrap_or("unknown").to_string()) } diff --git a/src/rust/provers/eprover.rs b/src/rust/provers/eprover.rs index b7e846d8..d899089a 100644 --- a/src/rust/provers/eprover.rs +++ b/src/rust/provers/eprover.rs @@ -87,6 +87,9 @@ impl ProverBackend for EProverBackend { let output = super::version_probe::run(&self.config, &["--version"]) .await .context("Failed to run eprover --version")?; + if !output.status.success() { + anyhow::bail!("EProver version probe failed: {}", output.status); + } let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout diff --git a/src/rust/provers/glpk.rs b/src/rust/provers/glpk.rs index 880ae5db..f0c42f91 100644 --- a/src/rust/provers/glpk.rs +++ b/src/rust/provers/glpk.rs @@ -41,6 +41,9 @@ impl ProverBackend for GLPKBackend { } async fn version(&self) -> Result { let o = super::version_probe::run(&self.config, &["--version"]).await?; + if !o.status.success() { + anyhow::bail!("GLPK version probe failed: {}", o.status); + } Ok(String::from_utf8_lossy(&o.stdout) .lines() .next() diff --git a/src/rust/provers/minizinc.rs b/src/rust/provers/minizinc.rs index 3a4fdb62..28f127e9 100644 --- a/src/rust/provers/minizinc.rs +++ b/src/rust/provers/minizinc.rs @@ -47,6 +47,9 @@ impl ProverBackend for MiniZincBackend { } async fn version(&self) -> Result { let o = super::version_probe::run(&self.config, &["--version"]).await?; + if !o.status.success() { + anyhow::bail!("MiniZinc version probe failed: {}", o.status); + } Ok(String::from_utf8_lossy(&o.stdout) .lines() .next() diff --git a/src/rust/provers/vampire.rs b/src/rust/provers/vampire.rs index 4876c0a8..c33ebf74 100644 --- a/src/rust/provers/vampire.rs +++ b/src/rust/provers/vampire.rs @@ -86,6 +86,9 @@ impl ProverBackend for VampireBackend { let output = super::version_probe::run(&self.config, &["--version"]) .await .context("Failed to run vampire --version")?; + if !output.status.success() { + anyhow::bail!("Vampire version probe failed: {}", output.status); + } let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src/rust/provers/version_probe.rs b/src/rust/provers/version_probe.rs index 1dd782ce..081e88dd 100644 --- a/src/rust/provers/version_probe.rs +++ b/src/rust/provers/version_probe.rs @@ -84,6 +84,60 @@ mod tests { } } + const STATUS_BACKENDS: [(&str, &str); 7] = [ + ("z3", "Z3 version 4.13.4"), + ("cvc5", "This is cvc5 version 1.1.2"), + ("alt-ergo", "2.6.2"), + ("vampire", "Vampire 4.9"), + ("eprover", "E 3.1"), + ("glpk", "GLPSOL--GLPK LP/MIP Solver 5.0"), + ("minizinc", "MiniZinc to FlatZinc converter, version 2.8.7"), + ]; + + #[tokio::test] + async fn factory_backends_reject_failed_probes_with_identifying_output() { + for (name, banner) in STATUS_BACKENDS { + for (exit_code, redirect) in [(1, ""), (7, ">&2")] { + let script = format!("printf '%s\\n' '{banner}' {redirect}; exit {exit_code}"); + let (_directory, config) = fixture(&script, 5); + let backend = ProverFactory::create(kind(name), config).unwrap(); + let error = backend.version().await.expect_err( + "version-like text must not turn a failed process into availability", + ); + let message = format!("{error:#}"); + assert!( + message.contains("version probe failed") + && message.contains(&format!("exit status: {exit_code}")), + "{name}: expected the fixture's actual exit status, got {message}" + ); + } + } + } + + #[tokio::test] + async fn successful_factory_probes_preserve_backend_output_selection() { + for (name, banner) in STATUS_BACKENDS { + for with_stderr in [false, true] { + let mut script = format!("printf '%s\\n' '{banner}' 'additional stdout'"); + if with_stderr { + script.push_str( + "\nprintf '%s\\n' 'stderr version banner' 'additional stderr' >&2", + ); + } + script.push_str("\nexit 0"); + let (_directory, config) = fixture(&script, 5); + let backend = ProverFactory::create(kind(name), config).unwrap(); + let actual = backend.version().await.unwrap(); + let expected = match (name, with_stderr) { + ("z3", _) => format!("{banner}\nadditional stdout"), + ("vampire", true) => "stderr version banner".to_string(), + _ => banner.to_string(), + }; + assert_eq!(actual, expected, "{name}: stderr={with_stderr}"); + } + } + } + #[tokio::test] async fn runner_preserves_real_status_and_both_output_streams() { let (_directory, config) = fixture( diff --git a/src/rust/provers/z3.rs b/src/rust/provers/z3.rs index 38d9b9f4..19fac7cc 100644 --- a/src/rust/provers/z3.rs +++ b/src/rust/provers/z3.rs @@ -236,6 +236,9 @@ impl ProverBackend for Z3Backend { let output = super::version_probe::run(&self.config, &["--version"]) .await .context("Failed to get Z3 version")?; + if !output.status.success() { + anyhow::bail!("Z3 version probe failed: {}", output.status); + } let version = String::from_utf8_lossy(&output.stdout).to_string(); Ok(version.trim().to_string()) From 30c9e2b82396c57181da9d1bb58668c4d92b3316 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:01:46 +0000 Subject: [PATCH 27/30] fix(ci): only apt-installed live provers refresh the apt index (stacked on #455) (#459) ## Summary Stacked on hyperpolymath/echidna#455, which rewrites the same lines of `live-provers.yml` and carries the repair for main's current startup failures. Merge #455 first; GitHub then retargets this PR to `main`. The T1 provision step ran `apt-get update` before every backend, including vampire, cvc5, alt-ergo and (with #455) chuffed, which install from GitHub release assets and never touch apt. On #446 the runner image's `packages.microsoft.com` source answered 403 twice, so `T1 / vampire` failed before any test ran ([job 118637174820](https://github.com/hyperpolymath/echidna/actions/runs/39425579312/job/118637174820)). The re-run passed, so it was the mirror and not the prover. ## Changes - `live-provers.yml`, T1 provision step: `apt-get update` moves into an `apt_install` helper. Only the apt-installed backends call it (z3, eprover, spass, glpk, minizinc). The release-asset backends no longer depend on the image's apt sources being reachable. An earlier version of this branch also pinned the T1 Guix job to `ubuntu-24.04`. I dropped that, because #455 makes the same fix with a pinned guix version and a running daemon. ## RSR Quality Checklist ### Required - [x] Tests pass: workflow-only change; the checks below ran on #455's tree plus this commit - [x] Code is formatted - [x] Linter is clean: `actionlint` with shellcheck, exit 0 - [x] No banned language patterns - [x] No `unsafe` blocks without `// SAFETY:` comments: n/a - [x] No banned functions: n/a - [x] SPDX license headers present on all new/modified source files - [x] No secrets, credentials, or `.env` files included ### As Applicable - [ ] Docs, deed, roadmap, changelog: n/a (CI provisioning only) ## Testing Run locally on this tree: - `actionlint` (with shellcheck) on `live-provers.yml`: clean. - `scripts/ci/test-issue-regressions.sh` (with mikefarah yq v4): passed. - `scripts/ci/check-allow-fail-expiry.sh`: 10 units, passed. - In the provision step, `sudo apt-get` now appears only inside the helper. - No `uses:` changed, so `actions.lock` is unaffected. This PR's own CI is the live test of the T1 matrix on the new step. --- _Generated by [Claude Code](https://claude.ai/code/session_015jzCHZx4Rt65NDaGLDrjLp)_ Co-authored-by: Claude --- .github/workflows/live-provers.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/live-provers.yml b/.github/workflows/live-provers.yml index 54cb4605..b5ed9ea2 100644 --- a/.github/workflows/live-provers.yml +++ b/.github/workflows/live-provers.yml @@ -90,10 +90,16 @@ jobs: run: | set -euo pipefail readonly https_protocol='=https' - # Retry apt-get update once on mirror failure before proceeding. - sudo apt-get update -qq || sudo apt-get update -qq --fix-missing + # Only the apt-installed provers refresh the apt index. The image's + # third-party sources (packages.microsoft.com) have answered 403 and + # failed the whole step for provers that never touch apt (vampire, + # PR #446). Retry the update once on mirror failure. + apt_install() { + sudo apt-get update -qq || sudo apt-get update -qq --fix-missing + sudo apt-get install -y --fix-missing "$@" + } case "${{ matrix.backend }}" in - z3) sudo apt-get install -y --fix-missing z3 ;; + z3) apt_install z3 ;; cvc5) # Since cvc5 1.2.0 releases ship as a zip with the binary at # bin/cvc5; the old bare `cvc5-Linux-static` asset no longer exists. curl --proto "$https_protocol" --proto-redir "$https_protocol" -sSL --max-time 120 -o /tmp/cvc5.zip \ @@ -107,16 +113,16 @@ jobs: https://github.com/vprover/vampire/releases/download/v5.0.1/vampire-Linux-X64.zip unzip -j -o /tmp/vampire.zip -d /tmp sudo install -m 0755 /tmp/vampire /usr/local/bin/vampire ;; - eprover) sudo apt-get install -y --fix-missing eprover ;; - spass) sudo apt-get install -y --fix-missing spass ;; + eprover) apt_install eprover ;; + spass) apt_install spass ;; alt-ergo) # alt-ergo is not in the Ubuntu 24.04 (noble) apt repos. # Install the prebuilt musl static binary from OCamlPro's # GitHub releases instead. curl --proto "$https_protocol" --proto-redir "$https_protocol" -sSL --max-time 120 -o /tmp/alt-ergo \ https://github.com/OCamlPro/alt-ergo/releases/download/v2.6.3/alt-ergo-v2.6.3-x86_64-linux-musl sudo install -m 0755 /tmp/alt-ergo /usr/local/bin/alt-ergo ;; - glpk) sudo apt-get install -y --fix-missing glpk-utils ;; - minizinc) sudo apt-get install -y --fix-missing minizinc ;; + glpk) apt_install glpk-utils ;; + minizinc) apt_install minizinc ;; chuffed) # Ubuntu's minizinc package does not include fzn-chuffed. # The upstream bundle does; authenticate it before extraction. bundle="$RUNNER_TEMP/MiniZincIDE-2.10.1-x86_64-linux-gnu.tgz" From 433ca4deb705c609045a411a49fafcc65faa7a17 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:14:28 +0000 Subject: [PATCH 28/30] fix(chapel): own runtime calls and finalization on one OS thread --- docs/ROADMAP.adoc | 22 +-- src/chapel/README.adoc | 73 +++++++- src/rust/proof_search.rs | 244 ++++++++++++++++++++++---- src/zig_ffi/build.zig | 13 ++ src/zig_ffi/chapel_bridge.zig | 115 ++++++++---- src/zig_ffi/runtime_owner.zig | 319 ++++++++++++++++++++++++++++++++++ 6 files changed, 705 insertions(+), 81 deletions(-) create mode 100644 src/zig_ffi/runtime_owner.zig diff --git a/docs/ROADMAP.adoc b/docs/ROADMAP.adoc index d2a00f3a..2d22964e 100644 --- a/docs/ROADMAP.adoc +++ b/docs/ROADMAP.adoc @@ -153,17 +153,17 @@ Verisim by `+goal_hash+`* Iris + VST + Flyspeck + HoTT absorption, with *online growth* adding tokens during training -|"`Chapel fully supported`" |*rewritten and CI-green 2026-05-30* — -`+src/chapel/+` compiles under chpl 2.3.0 / 2.8.0 (static lib, apt -Chapel ships `+CHPL_LIB_PIC=none+` only); L2.2 -`+parallelProofSearchSpeculative+` (first-success-wins atomic CAS) -shipped next to best-of `+parallelProofSearch+`; `+chapel-ci.yml+` -chapel-build + zig-ffi strict; aggregation invariants proved in -`+proofs/agda/ParallelSoundness.agda+` with zero -postulate/admit/believe_me. L2.3 cancel-token preemption next -|*`+dispatch.rs+` picks Chapel‑parallel dispatch by config*; runtime -init + cancellation + error propagation wired; ≥1 OoM speedup on -portfolio solves +|"`Chapel fully supported`" |*partial native qualification* — the +`f160493` hosted real-library job linked and queried the runtime version, but +its no-prover FFI probe and process exit aborted in qthreads task-state lookup. +The bridge repair assigns initialization, calls, allocation cleanup, and +finalization to one stable OS thread without changing the task runtime. +Fresh runtime qualification remains required; the existing issue #416 +allow-failure boundary is unchanged. See +link:../src/chapel/README.adoc[the source-bound receipt and lifecycle contract]. +|*`+dispatch.rs+` picks Chapel-parallel dispatch by config*; qualify real +search/result cleanup and per-prover timeouts. Hard caller cancellation and +portfolio speedup claims require their own implementation and evidence. |"`Cap’n Proto serialisation`" |0 `+.capnp+` files |*`+crates/echidna-wire/+`* contains schemas for ProofState / Goal / diff --git a/src/chapel/README.adoc b/src/chapel/README.adoc index 9d407fb5..e37211da 100644 --- a/src/chapel/README.adoc +++ b/src/chapel/README.adoc @@ -6,9 +6,16 @@ the optional Rust `chapel` feature. == Current build boundary -The real native link repair requires exact-source hosted qualification. -The stub build deliberately reports Chapel unavailable; it is a separate -fallback test and cannot qualify the real runtime. +The hosted real-library job +https://github.com/hyperpolymath/echidna/actions/runs/41929785899/job/126923985200[126923985200] +on source `f160493cc1e0331d649b30cf94b3d03c40773b1e` qualified the native link, +the compiler-layout controls, and the runtime version query. Its no-prover child +and process shutdown aborted in the qthreads task-state assertion. The job's +allow-failure workflow conclusion is not a passed runtime test. + +The thread-ownership repair below requires fresh exact-source hosted +qualification. The stub build deliberately reports Chapel unavailable; it is +a separate fallback test and cannot qualify the real runtime. The final Rust executable links three parts in order: the static Zig bridge, `libechidna_chapel.a`, and the runtime/dependency flags returned by the installed @@ -17,12 +24,50 @@ Chapel compiler's official `util/config/compileline --libraries` helper. supports native Linux and refuses unsupported compiler flags instead of silently discarding them. -The bridge initializes the real runtime and both generated module initializers -once before an export is called, then registers finalization at process exit. -The installed `echidna_chapel_ffi.mode` records real versus stub build intent; -tests also assert actual availability, version, and a no-prover failure result. +The bridge owns the real runtime on one stable OS thread. Initialization, both +generated module initializers, every Chapel export, and finalization run there. +Foreign Rust/Zig caller threads submit synchronous work to that owner. Requests +are serialized at this boundary; each Chapel search still uses its existing +parallel tasks internally. Re-entry from the owner is refused. + +The owner copies result strings into bridge-owned storage and frees the original +Chapel allocation before completing a request, including allocation-error paths. +Rust receives independent copies and keeps its existing `echidna_free_*` +obligation. At normal process exit, the bridge stops admitting work, completes +the accepted request, finalizes on the owner, and joins that thread. A native +fatal exit cannot be converted into successful cleanup. + +This follows the pinned Chapel sources: +https://github.com/chapel-lang/chapel/blob/2.8.0/runtime/src/tasks/qthreads/tasks-qthreads.c[`chpl_task_init`] +records the initializing pthread; +https://github.com/chapel-lang/chapel/blob/2.8.0/runtime/include/tasks/qthreads/chpl-tasks-impl-fns.h[`chpl_qthread_get_tasklocal`] +returns its process task bundle only to that pthread or an existing Chapel/task +thread; and +https://github.com/chapel-lang/chapel/blob/2.8.0/modules/internal/ExportWrappers.chpl[library setup/cleanup] +creates and releases the task-local dynamic end-count. +The public +https://github.com/chapel-lang/chapel/blob/2.8.0/doc/rst/technotes/libraries.rst[library API] +requires initialization before exports and finalization after them. The bridge +keeps the installed task runtime and its assertions intact. + +The installed `echidna_chapel_ffi.mode` records real versus stub build intent. That metadata is not an attestation of artifact contents. +=== Timeout and cancellation boundary + +The Rust strategy remains synchronous. It accepts positive whole-second +per-prover timeouts representable by the C ABI and rejects zero, fractional, +or overflowing durations before entering FFI; those values must not silently +select Chapel's native default timeout. The native C API retains its documented +zero-means-default convention. + +Queueing, runtime initialization, and availability checks are outside that +per-prover timer. This repair does not provide a hard whole-call deadline or +safe asynchronous cancellation of an in-process Chapel export. A caller must +not terminate the owner thread. Normal shutdown drains accepted work before +finalization. The existing speculative-search loser cancellation remains a +separate Chapel operation; this bridge continues to invoke best-of search. + == Build and checks Install the same Chapel 2.8.0 and Zig 0.14.0 used by @@ -53,8 +98,18 @@ optional bridge was built and no mode was requested. The real CI consumer downloads the same-run archive into that directory, checks its exports, runs the native-link refusal controls, and executes Rust -FFI tests. The empty-PATH child test exercises a real search/result/free cycle -without starting an external prover. It checks failure, never a proof claim. +FFI tests. The empty-PATH child test runs concurrent Rust callers through real +search/result/free cycles without starting an external prover, then requires a +normal process exit. A separate isolated finite prover records its PID and +sleeps longer than its configured per-prover limit; the test requires a failed +search, bounded return, and the prover already stopped. Its parent has a +separate deadline and reaps a stuck test process. Neither fixture is proof +evidence. + +The Zig test target also exercises actual OS threads with an instrumented +runtime: one initialization/finalization thread, concurrent callers, shutdown +while accepted work is in flight, refusal after shutdown, and callback +re-entry refusal. These controls do not substitute for the real Chapel tests. The existing CI waiver and its expiry are unchanged. A source patch, stub success, or separate Chapel smoke target does not establish that this complete diff --git a/src/rust/proof_search.rs b/src/rust/proof_search.rs index ddbaab21..3c90d3bf 100644 --- a/src/rust/proof_search.rs +++ b/src/rust/proof_search.rs @@ -163,16 +163,28 @@ mod chapel_ffi { fn echidna_free_string(s: *const c_char); } + /// Synchronous Chapel search, serialized through the bridge's OS owner. + /// + /// The timeout is a per-prover whole-second limit. This API does not offer + /// caller cancellation or a hard deadline covering queueing and runtime setup. pub struct ChapelParallelSearch; + fn chapel_timeout_seconds(timeout: Duration) -> Result { + if timeout.is_zero() || timeout.subsec_nanos() != 0 { + anyhow::bail!("Chapel timeout must be a positive whole number of seconds"); + } + c_int::try_from(timeout.as_secs()).context("Chapel timeout exceeds the C ABI range") + } + impl ProofSearchStrategy for ChapelParallelSearch { fn search(&self, goal: &str, timeout: Duration) -> Result { // Convert goal to C string let c_goal = CString::new(goal).context("Invalid goal string")?; - let timeout_secs = timeout.as_secs() as c_int; + let timeout_secs = chapel_timeout_seconds(timeout)?; // SAFETY: c_goal is a valid CString, null prover_ids with 0 count - // means "use all 30 provers". Chapel FFI allocates the result. + // means "use all 30 provers". The bridge runs on its runtime owner + // and returns independent copies allocated by the bridge. let mut c_result = unsafe { echidna_prove_parallel(c_goal.as_ptr(), std::ptr::null(), 0, timeout_secs) }; @@ -207,7 +219,7 @@ mod chapel_ffi { strategy_used: self.name().to_string(), }; - // SAFETY: c_result was allocated by Chapel FFI and must be freed once. + // SAFETY: c_result contains bridge-owned copies and must be freed once. unsafe { echidna_free_proof_result(&mut c_result as *mut _); } @@ -266,6 +278,81 @@ mod chapel_ffi { mod tests { use super::*; + #[test] + fn chapel_timeout_cannot_become_the_native_default_or_wrap() { + assert_eq!(chapel_timeout_seconds(Duration::from_secs(1)).unwrap(), 1); + assert_eq!( + chapel_timeout_seconds(Duration::from_secs(c_int::MAX as u64)).unwrap(), + c_int::MAX + ); + for timeout in [ + Duration::ZERO, + Duration::from_millis(500), + Duration::from_millis(1500), + Duration::from_secs(c_int::MAX as u64 + 1), + Duration::MAX, + ] { + assert!(chapel_timeout_seconds(timeout).is_err(), "{timeout:?}"); + } + } + + #[cfg(echidna_chapel_real)] + fn real_probe_command(test_name: &str, child_env: &str) -> std::process::Command { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .arg("--exact") + .arg(format!("proof_search::chapel_ffi::tests::{test_name}")) + .arg("--nocapture") + .env(child_env, "1") + .stdin(std::process::Stdio::null()); + command + } + + #[cfg(echidna_chapel_real)] + fn run_real_probe(command: &mut std::process::Command, marker: &str) { + use std::fs::File; + use std::process::{Child, Stdio}; + + struct Reap(Child); + impl Drop for Reap { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + // Files avoid pipe backpressure while waiting and retain diagnostics + // if a native assertion aborts before the child test reports failure. + let logs = tempfile::tempdir().expect("probe log directory"); + let stdout_path = logs.path().join("stdout"); + let stderr_path = logs.path().join("stderr"); + command + .stdout(Stdio::from(File::create(&stdout_path).unwrap())) + .stderr(Stdio::from(File::create(&stderr_path).unwrap())); + let mut child = Reap(command.spawn().expect("launch isolated real-runtime probe")); + let started = Instant::now(); + let (status, timed_out) = loop { + if let Some(status) = child.0.try_wait().expect("inspect real-runtime probe") { + break (status, false); + } + if started.elapsed() >= Duration::from_secs(30) { + child.0.kill().expect("stop timed-out real-runtime probe"); + break (child.0.wait().expect("reap real-runtime probe"), true); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let stdout = std::fs::read_to_string(stdout_path).expect("probe stdout"); + let stderr = std::fs::read_to_string(stderr_path).expect("probe stderr"); + assert!( + !timed_out && status.success(), + "real-runtime child failed (timeout={timed_out}, {status}):\n{stdout}\n{stderr}" + ); + assert!( + stdout.contains(marker), + "the exact child test did not complete its real-runtime assertions: {stdout}" + ); + } + #[cfg(echidna_chapel_stubs)] #[test] fn stub_mode_cannot_claim_a_real_runtime() { @@ -291,17 +378,33 @@ mod chapel_ffi { fn real_search_without_provers_fails_and_frees_result() { const CHILD: &str = "ECHIDNA_CHAPEL_EMPTY_PATH_CHILD"; if std::env::var_os(CHILD).is_some() { - let strategy = ChapelParallelSearch::new().expect("real Chapel runtime"); - assert_eq!(ChapelParallelSearch::available_prover_count(), 0); - for _ in 0..2 { - let result = strategy - .search("True", Duration::from_secs(1)) - .expect("FFI returns a result when no prover is available"); - assert!(!result.success); - assert_eq!( - result.error_message.as_deref(), - Some("All provers exhausted") - ); + let start = std::sync::Arc::new(std::sync::Barrier::new(4)); + let callers: Vec<_> = (0..4) + .map(|_| { + let start = start.clone(); + std::thread::spawn(move || { + start.wait(); + let strategy = ChapelParallelSearch::new().expect("real Chapel runtime"); + assert_eq!(ChapelParallelSearch::available_prover_count(), 0); + for _ in 0..2 { + assert!(strategy.available()); + assert!(ChapelParallelSearch::get_version() + .unwrap() + .starts_with("ECHIDNA Chapel Metalayer ")); + let result = strategy + .search("True", Duration::from_secs(1)) + .expect("FFI returns a result when no prover is available"); + assert!(!result.success); + assert_eq!( + result.error_message.as_deref(), + Some("All provers exhausted") + ); + } + }) + }) + .collect(); + for caller in callers { + caller.join().expect("concurrent Chapel caller"); } println!("ECHIDNA_REAL_CHAPEL_NO_PROVER_PROBE_COMPLETED"); return; @@ -311,25 +414,102 @@ mod chapel_ffi { // No global environment mutation races with other Rust tests, and // no external prover can be dispatched by this allocation/free probe. let empty_path = tempfile::tempdir().expect("empty prover search directory"); - let result = std::process::Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "proof_search::chapel_ffi::tests::real_search_without_provers_fails_and_frees_result", - "--nocapture", - ]) - .env(CHILD, "1") - .env("PATH", empty_path.path()) - .output() - .expect("launch isolated real-runtime probe"); - assert!( - result.status.success(), - "real-runtime child failed: {}", - String::from_utf8_lossy(&result.stderr) + let mut command = real_probe_command( + "real_search_without_provers_fails_and_frees_result", + CHILD, ); - assert!( - String::from_utf8_lossy(&result.stdout) - .contains("ECHIDNA_REAL_CHAPEL_NO_PROVER_PROBE_COMPLETED"), - "the exact child test did not complete its real-runtime assertions" + command.env("PATH", empty_path.path()); + run_real_probe( + &mut command, + "ECHIDNA_REAL_CHAPEL_NO_PROVER_PROBE_COMPLETED", + ); + } + + #[cfg(all(echidna_chapel_real, target_os = "linux"))] + fn probe_process_stopped(pid: u32) -> std::io::Result { + match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(stat) => { + let state = stat + .rsplit_once(')') + .and_then(|(_, fields)| fields.split_whitespace().next()) + .ok_or_else(|| std::io::Error::other("malformed process state"))?; + Ok(matches!(state, "Z" | "X" | "x")) + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(error), + } + } + + #[cfg(all(echidna_chapel_real, target_os = "linux"))] + #[test] + fn real_search_deadline_stops_the_owned_prover() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + const CHILD: &str = "ECHIDNA_CHAPEL_DEADLINE_CHILD"; + const PID_FILE: &str = "ECHIDNA_CHAPEL_PROVER_PID"; + if std::env::var_os(CHILD).is_some() { + let strategy = ChapelParallelSearch::new().expect("real Chapel runtime"); + assert_eq!(ChapelParallelSearch::available_prover_count(), 1); + let started = Instant::now(); + let result = strategy.search("True", Duration::from_secs(1)); + let elapsed = started.elapsed(); + let pid: u32 = std::fs::read_to_string(std::env::var_os(PID_FILE).unwrap()) + .expect("the finite prover actually started") + .trim() + .parse() + .expect("prover PID"); + let stopped_before_cleanup = probe_process_stopped(pid); + + // Cleanup precedes assertions. The fake prover also has its own + // finite four-second lifetime if the parent process must abort. + if !matches!(stopped_before_cleanup, Ok(true)) { + let _ = std::process::Command::new("/bin/kill") + .args(["-KILL", &pid.to_string()]) + .status(); + let cleanup = Instant::now(); + while matches!(probe_process_stopped(pid), Ok(false)) + && cleanup.elapsed() < Duration::from_secs(2) + { + std::thread::sleep(Duration::from_millis(10)); + } + } + + assert!( + matches!(stopped_before_cleanup, Ok(true)), + "deadline returned with a live or unobservable prover: {stopped_before_cleanup:?}" + ); + assert!(elapsed < Duration::from_secs(3), "deadline took {elapsed:?}"); + let result = result.expect("native timeout returns a failed search result"); + assert!(!result.success); + assert_eq!( + result.error_message.as_deref(), + Some("All provers exhausted") + ); + println!("ECHIDNA_REAL_CHAPEL_DEADLINE_PROBE_COMPLETED"); + return; + } + + let fixture = tempfile::tempdir().expect("isolated finite prover directory"); + let which = which::which("which").expect("availability probe executable"); + symlink(which, fixture.path().join("which")).expect("isolated which"); + let prover = fixture.path().join("z3"); + std::fs::write( + &prover, + "#!/bin/sh\nprintf '%s\\n' \"$$\" > \"$ECHIDNA_CHAPEL_PROVER_PID\"\nexec /bin/sleep 4\n", + ) + .expect("write finite prover"); + std::fs::set_permissions(&prover, std::fs::Permissions::from_mode(0o700)) + .expect("make finite prover executable"); + let mut command = real_probe_command( + "real_search_deadline_stops_the_owned_prover", + CHILD, + ); + command + .env("PATH", fixture.path()) + .env(PID_FILE, fixture.path().join("prover.pid")); + run_real_probe( + &mut command, + "ECHIDNA_REAL_CHAPEL_DEADLINE_PROBE_COMPLETED", ); } } diff --git a/src/zig_ffi/build.zig b/src/zig_ffi/build.zig index 1d185ee4..ec861372 100644 --- a/src/zig_ffi/build.zig +++ b/src/zig_ffi/build.zig @@ -83,4 +83,17 @@ pub fn build(b: *std.Build) void { const run_lib_tests = b.addRunArtifact(lib_tests); const test_step = b.step("test", "Run FFI tests with explicit Chapel stubs"); test_step.dependOn(&run_lib_tests.step); + + // Exercise real OS-thread ownership and shutdown ordering independently + // of the deliberately unavailable Chapel stubs. + const owner_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("runtime_owner.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + const run_owner_tests = b.addRunArtifact(owner_tests); + test_step.dependOn(&run_owner_tests.step); } diff --git a/src/zig_ffi/chapel_bridge.zig b/src/zig_ffi/chapel_bridge.zig index c9c8bd33..794ef94a 100644 --- a/src/zig_ffi/chapel_bridge.zig +++ b/src/zig_ffi/chapel_bridge.zig @@ -11,6 +11,7 @@ const std = @import("std"); const options = @import("chapel_options"); +const runtime_owner = @import("runtime_owner.zig"); //============================================================================== // Import Chapel's C API @@ -21,9 +22,10 @@ const chapel = @cImport({ @cInclude("stdlib.h"); }); -// Chapel 2.8's library ABI requires runtime + exported module initialization -// before any library call. The once guard is shared by every bridge entry; -// stub builds do not reference or initialize a Chapel runtime. +// Chapel's qthreads runtime binds its process task bundle to the OS thread +// that initializes it. A once guard alone does not make foreign threads valid. +// Every real export, Chapel allocation/free, and finalization uses this owner. +// Stub builds call only the unavailable stubs and start no native runtime. const ChapelRuntime = struct { var program_name = [_:0]u8{ 'e', 'c', 'h', 'i', 'd', 'n', 'a' }; var arguments = [_]?[*:0]u8{ &program_name, null }; @@ -33,24 +35,54 @@ const ChapelRuntime = struct { extern fn chpl__init_parallel_proof_search(line: i64, file: i32) void; extern fn chpl__init_chapel_ffi_exports(line: i64, file: i32) void; - fn initialize() void { + pub fn initialize(_: *@This()) void { chpl_library_init(1, &arguments); chpl__init_parallel_proof_search(0, 0); chpl__init_chapel_ffi_exports(0, 0); - if (chapel.atexit(finalize) != 0) { - @panic("could not register Chapel runtime finalization"); - } } - fn finalize() callconv(.c) void { + pub fn finalize(_: *@This()) void { chpl_library_finalize(); } }; -var chapel_runtime_once = std.once(ChapelRuntime.initialize); +var chapel_owner = runtime_owner.Owner(ChapelRuntime).init(.{}); +var chapel_shutdown_once = std.once(registerChapelShutdown); -fn ensureChapelInitialized() void { - if (!options.chapel_stubs) chapel_runtime_once.call(); +fn registerChapelShutdown() void { + if (chapel.atexit(shutdownChapel) != 0) { + // Refuse startup if normal process exit cannot join the owner. + chapel_owner.shutdown() catch unreachable; + } +} + +fn shutdownChapel() callconv(.c) void { + chapel_owner.shutdown() catch { + // A fatal exit inside Chapel could invoke atexit on its own worker. + // Recursive finalization/self-join is unsafe; retain a failed process. + std.process.abort(); + }; +} + +fn callOnChapelOwner(comptime function: anytype, args: anytype, result: anytype) runtime_owner.CallError!void { + const Context = struct { + arguments: @TypeOf(args), + result: @TypeOf(result), + + fn run(raw: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.result.* = @call(.auto, function, self.arguments); + } + }; + var context = Context{ .arguments = args, .result = result }; + if (options.chapel_stubs) { + Context.run(&context); + return; + } + + chapel_shutdown_once.call(); + var work = runtime_owner.Work{ .context = &context, .run = Context.run }; + try chapel_owner.call(&work); } //============================================================================== @@ -185,6 +217,7 @@ pub const ProofResult = struct { .error_message = null, .category = null, }; + errdefer result.deinit(allocator); // Parse prover ID if (c_result.prover_id >= 0 and c_result.prover_id < @as(c_int, ProverKind.count)) { @@ -240,14 +273,23 @@ pub const ChapelError = error{ /// Check if Chapel runtime is available pub fn isChapelAvailable() bool { - ensureChapelInitialized(); - const result = chapel.chapel_is_available(); - return result != 0; + var result: bool = undefined; + callOnChapelOwner(isChapelAvailableOnOwner, .{}, &result) catch return false; + return result; +} + +fn isChapelAvailableOnOwner() bool { + return chapel.chapel_is_available() != 0; } /// Get Chapel metalayer version string pub fn getChapelVersion(allocator: std.mem.Allocator) ![]const u8 { - ensureChapelInitialized(); + var result: anyerror![]const u8 = undefined; + try callOnChapelOwner(getChapelVersionOnOwner, .{allocator}, &result); + return result; +} + +fn getChapelVersionOnOwner(allocator: std.mem.Allocator) ![]const u8 { const c_version = chapel.chapel_get_version(); if (c_version == null) { return error.ChapelRuntimeUnavailable; @@ -259,14 +301,24 @@ pub fn getChapelVersion(allocator: std.mem.Allocator) ![]const u8 { /// Get count of provers available on this system (0-30) pub fn availableProverCount() u32 { - ensureChapelInitialized(); + var result: u32 = undefined; + callOnChapelOwner(availableProverCountOnOwner, .{}, &result) catch return 0; + return result; +} + +fn availableProverCountOnOwner() u32 { const count = chapel.chapel_available_prover_count(); return if (count >= 0) @intCast(count) else 0; } /// Check if a specific prover is available pub fn isProverAvailable(prover: ProverKind) bool { - ensureChapelInitialized(); + var result: bool = undefined; + callOnChapelOwner(isProverAvailableOnOwner, .{prover}, &result) catch return false; + return result; +} + +fn isProverAvailableOnOwner(prover: ProverKind) bool { return chapel.chapel_is_prover_available(@intFromEnum(prover)) != 0; } @@ -291,10 +343,18 @@ pub fn parallelProofSearch( timeout_secs: u32, allocator: std.mem.Allocator, ) !ProofResult { - // Validate Chapel is available - if (!isChapelAvailable()) { - return error.ChapelRuntimeUnavailable; - } + var result: anyerror!ProofResult = undefined; + try callOnChapelOwner(parallelProofSearchOnOwner, .{ goal, provers, timeout_secs, allocator }, &result); + return result; +} + +fn parallelProofSearchOnOwner( + goal: []const u8, + provers: ?[]const ProverKind, + timeout_secs: u32, + allocator: std.mem.Allocator, +) !ProofResult { + if (!isChapelAvailableOnOwner()) return error.ChapelRuntimeUnavailable; // Convert goal to C string const goal_cstr = try allocator.dupeZ(u8, goal); @@ -323,13 +383,10 @@ pub fn parallelProofSearch( @intCast(timeout_secs), ); - // Convert Chapel result to Zig (copies strings) - const result = try ProofResult.fromChapel(c_result, allocator); - - // Free Chapel-allocated strings - chapel.chapel_free_result(&c_result); - - return result; + // Copy and free on the owner even when copying fails. Only bridge-owned + // strings cross back to the caller, so shutdown never races Chapel data. + defer chapel.chapel_free_result(&c_result); + return ProofResult.fromChapel(c_result, allocator); } //============================================================================== @@ -449,8 +506,8 @@ export fn echidna_chapel_is_available() c_int { export fn echidna_chapel_get_version() ?[*:0]const u8 { const allocator = std.heap.c_allocator; const version = getChapelVersion(allocator) catch return null; + defer allocator.free(version); const c_version = allocator.dupeZ(u8, version) catch return null; - allocator.free(version); return c_version.ptr; } diff --git a/src/zig_ffi/runtime_owner.zig b/src/zig_ffi/runtime_owner.zig new file mode 100644 index 00000000..88bd7438 --- /dev/null +++ b/src/zig_ffi/runtime_owner.zig @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// A single OS thread owns a native runtime from initialization to finalization. +// Work is synchronous: the caller retains its context until call() completes. +const std = @import("std"); + +pub const Work = struct { + context: *anyopaque, + run: *const fn (*anyopaque) void, + completed: bool = false, +}; + +pub const CallError = error{ RuntimeUnavailable, ReentrantCall }; + +pub fn Owner(comptime Runtime: type) type { + return struct { + const Self = @This(); + const State = enum { idle, running, stopping, stopped, failed }; + + runtime: Runtime, + mutex: std.Thread.Mutex = .{}, + work_available: std.Thread.Condition = .{}, + changed: std.Thread.Condition = .{}, + state: State = .idle, + worker: ?std.Thread = null, + worker_id: ?std.Thread.Id = null, + pending: ?*Work = null, + + pub fn init(runtime: Runtime) Self { + return .{ .runtime = runtime }; + } + + // No context or native result escapes the completion handshake. + // Re-entry from a native callback is refused, not queued behind itself. + pub fn call(self: *Self, work: *Work) CallError!void { + self.mutex.lock(); + defer self.mutex.unlock(); + + if (self.worker_id == std.Thread.getCurrentId()) return error.ReentrantCall; + if (self.state == .idle) { + self.state = .running; + self.worker = std.Thread.spawn(.{}, run, .{self}) catch { + self.state = .failed; + return error.RuntimeUnavailable; + }; + } + + while (self.pending != null and self.state == .running) { + self.changed.wait(&self.mutex); + } + if (self.state != .running) return error.RuntimeUnavailable; + + work.completed = false; + self.pending = work; + self.work_available.signal(); + while (!work.completed) self.changed.wait(&self.mutex); + } + + // Stop admission, finish the one accepted request, finalize on its + // owner, and join. Concurrent shutdown callers wait for the same join. + // An idle or closed owner is never restarted. + pub fn shutdown(self: *Self) CallError!void { + self.mutex.lock(); + if (self.worker_id == std.Thread.getCurrentId()) { + self.mutex.unlock(); + return error.ReentrantCall; + } + + switch (self.state) { + .idle, .failed => { + self.state = .stopped; + self.mutex.unlock(); + return; + }, + .stopped => { + self.mutex.unlock(); + return; + }, + .stopping => { + while (self.state != .stopped) self.changed.wait(&self.mutex); + self.mutex.unlock(); + return; + }, + .running => {}, + } + + self.state = .stopping; + self.work_available.signal(); + self.changed.broadcast(); + const worker = self.worker.?; + self.mutex.unlock(); + + worker.join(); + + self.mutex.lock(); + self.worker = null; + self.worker_id = null; + self.state = .stopped; + self.changed.broadcast(); + self.mutex.unlock(); + } + + fn run(self: *Self) void { + self.mutex.lock(); + self.worker_id = std.Thread.getCurrentId(); + self.mutex.unlock(); + + self.runtime.initialize(); + self.mutex.lock(); + while (true) { + while (self.pending == null and self.state == .running) { + self.work_available.wait(&self.mutex); + } + const work = self.pending orelse break; + self.mutex.unlock(); + + work.run(work.context); + + self.mutex.lock(); + work.completed = true; + self.pending = null; + self.changed.broadcast(); + } + self.mutex.unlock(); + self.runtime.finalize(); + } + }; +} + +const Probe = struct { + init_id: std.Thread.Id = 0, + finalize_id: std.Thread.Id = 0, + initialized: usize = 0, + finalized: usize = 0, + calls: usize = 0, + wrong_thread: bool = false, + + fn initialize(self: *@This()) void { + self.init_id = std.Thread.getCurrentId(); + self.initialized += 1; + } + + fn finalize(self: *@This()) void { + self.finalize_id = std.Thread.getCurrentId(); + self.finalized += 1; + } + + fn work(raw: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.wrong_thread = self.wrong_thread or self.init_id != std.Thread.getCurrentId(); + self.calls += 1; + } +}; + +test "one OS thread owns init concurrent work and finalization" { + const TestOwner = Owner(*Probe); + var probe = Probe{}; + var owner = TestOwner.init(&probe); + defer owner.shutdown() catch unreachable; + + const Caller = struct { + owner: *TestOwner, + probe: *Probe, + failed: bool = false, + id: std.Thread.Id = 0, + + fn run(self: *@This()) void { + self.id = std.Thread.getCurrentId(); + var work = Work{ .context = self.probe, .run = Probe.work }; + for (0..2) |_| self.owner.call(&work) catch { + self.failed = true; + return; + }; + } + }; + var callers: [4]Caller = undefined; + var threads: [4]std.Thread = undefined; + var started: usize = 0; + defer for (threads[0..started]) |thread| thread.join(); + for (&callers, &threads) |*caller, *thread| { + caller.* = .{ .owner = &owner, .probe = &probe }; + thread.* = try std.Thread.spawn(.{}, Caller.run, .{caller}); + started += 1; + } + for (threads[0..started]) |thread| thread.join(); + started = 0; + try owner.shutdown(); + + try std.testing.expectEqual(@as(usize, 1), probe.initialized); + try std.testing.expectEqual(@as(usize, 1), probe.finalized); + try std.testing.expectEqual(@as(usize, 8), probe.calls); + try std.testing.expectEqual(probe.init_id, probe.finalize_id); + try std.testing.expect(!probe.wrong_thread); + for (callers) |caller| { + try std.testing.expect(!caller.failed); + try std.testing.expect(caller.id != probe.init_id); + } + var refused = Work{ .context = &probe, .run = Probe.work }; + try std.testing.expectError(error.RuntimeUnavailable, owner.call(&refused)); + try std.testing.expectEqual(@as(usize, 8), probe.calls); +} + +test "shutdown drains accepted work and refuses new work before joining" { + const TestOwner = Owner(*Probe); + var probe = Probe{}; + var owner = TestOwner.init(&probe); + defer owner.shutdown() catch unreachable; + + const Blocked = struct { + owner: *TestOwner, + mutex: std.Thread.Mutex = .{}, + changed: std.Thread.Condition = .{}, + entered: bool = false, + release: bool = false, + call_failed: bool = false, + shutdown_failed: bool = false, + + fn work(raw: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.mutex.lock(); + self.entered = true; + self.changed.broadcast(); + while (!self.release) self.changed.wait(&self.mutex); + self.mutex.unlock(); + } + + fn call(self: *@This()) void { + var request = Work{ .context = self, .run = work }; + self.owner.call(&request) catch { + self.call_failed = true; + }; + } + + fn stop(self: *@This()) void { + self.owner.shutdown() catch { + self.shutdown_failed = true; + }; + } + }; + var blocked = Blocked{ .owner = &owner }; + const caller = try std.Thread.spawn(.{}, Blocked.call, .{&blocked}); + var caller_joined = false; + defer if (!caller_joined) caller.join(); + // Release the callback even if starting the shutdown thread fails. + defer { + blocked.mutex.lock(); + blocked.release = true; + blocked.changed.broadcast(); + blocked.mutex.unlock(); + } + + blocked.mutex.lock(); + while (!blocked.entered) blocked.changed.wait(&blocked.mutex); + blocked.mutex.unlock(); + + const stopper = try std.Thread.spawn(.{}, Blocked.stop, .{&blocked}); + owner.mutex.lock(); + while (owner.state != .stopping) owner.changed.wait(&owner.mutex); + owner.mutex.unlock(); + var refused = Work{ .context = &probe, .run = Probe.work }; + const rejected = if (owner.call(&refused)) |_| false else |err| err == error.RuntimeUnavailable; + + blocked.mutex.lock(); + blocked.release = true; + blocked.changed.broadcast(); + blocked.mutex.unlock(); + stopper.join(); + caller.join(); + caller_joined = true; + // Both the submitter and owner have completed before inspecting results. + try std.testing.expect(rejected); + try std.testing.expect(!blocked.call_failed); + try std.testing.expect(!blocked.shutdown_failed); + try std.testing.expectEqual(@as(usize, 1), probe.finalized); + try std.testing.expectEqual(probe.init_id, probe.finalize_id); + try std.testing.expectEqual(@as(usize, 0), probe.calls); +} + +test "owner callbacks cannot re-enter or finalize themselves" { + const TestOwner = Owner(*Probe); + var probe = Probe{}; + var owner = TestOwner.init(&probe); + defer owner.shutdown() catch unreachable; + const Callback = struct { + owner: *TestOwner, + probe: *Probe, + call_refused: bool = false, + stop_refused: bool = false, + + fn work(raw: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + var nested = Work{ .context = self.probe, .run = Probe.work }; + self.call_refused = if (self.owner.call(&nested)) |_| false else |err| err == error.ReentrantCall; + self.stop_refused = if (self.owner.shutdown()) |_| false else |err| err == error.ReentrantCall; + } + }; + var callback = Callback{ .owner = &owner, .probe = &probe }; + var request = Work{ .context = &callback, .run = Callback.work }; + try owner.call(&request); + var following = Work{ .context = &probe, .run = Probe.work }; + try owner.call(&following); + try owner.shutdown(); + + try std.testing.expect(callback.call_refused and callback.stop_refused); + try std.testing.expectEqual(@as(usize, 1), probe.calls); + try std.testing.expectEqual(@as(usize, 1), probe.finalized); +} + +test "shutdown before initialization never starts the runtime" { + var probe = Probe{}; + var owner = Owner(*Probe).init(&probe); + try owner.shutdown(); + try owner.shutdown(); + var request = Work{ .context = &probe, .run = Probe.work }; + try std.testing.expectError(error.RuntimeUnavailable, owner.call(&request)); + try std.testing.expectEqual(@as(usize, 0), probe.initialized); + try std.testing.expectEqual(@as(usize, 0), probe.finalized); +} From d5a63000026babdb4d2e8f0fd52e4f59602c9597 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:16:41 +0000 Subject: [PATCH 29/30] style(chapel): apply hosted rustfmt correction to layout control --- build_support/chapel.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/build_support/chapel.rs b/build_support/chapel.rs index a8a1e359..2208710f 100644 --- a/build_support/chapel.rs +++ b/build_support/chapel.rs @@ -345,10 +345,7 @@ mod tests { fn compiler_layout_rejects_incomplete_ambiguous_and_relative_headers() { let invalid = [ String::new(), - PREFIX_SETTINGS.replace( - "CHPL_RUNTIME_LIB: /prefix/lib/chapel/2.8/runtime/lib\n", - "", - ), + PREFIX_SETTINGS.replace("CHPL_RUNTIME_LIB: /prefix/lib/chapel/2.8/runtime/lib\n", ""), PREFIX_SETTINGS.replace( "CHPL_RUNTIME_LIB: /prefix/lib/chapel/2.8/runtime/lib", "CHPL_RUNTIME_LIB: relative/lib", From 9db3ef33f47874ef1fe2e9f57e52256b64014add Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:23:53 +0000 Subject: [PATCH 30/30] fix(chapel): preserve caller allocators and refuse active exit joins --- src/chapel/README.adoc | 54 ++++-- src/zig_ffi/chapel_bridge.zig | 321 ++++++++++++++++++++++++++++++++-- src/zig_ffi/runtime_owner.zig | 117 ++++++++++++- 3 files changed, 458 insertions(+), 34 deletions(-) diff --git a/src/chapel/README.adoc b/src/chapel/README.adoc index e37211da..6bd0b1b2 100644 --- a/src/chapel/README.adoc +++ b/src/chapel/README.adoc @@ -30,12 +30,21 @@ Foreign Rust/Zig caller threads submit synchronous work to that owner. Requests are serialized at this boundary; each Chapel search still uses its existing parallel tasks internally. Re-entry from the owner is refused. -The owner copies result strings into bridge-owned storage and frees the original -Chapel allocation before completing a request, including allocation-error paths. -Rust receives independent copies and keeps its existing `echidna_free_*` -obligation. At normal process exit, the bridge stops admitting work, completes -the accepted request, finalizes on the owner, and joins that thread. A native -fatal exit cannot be converted into successful cleanup. +The owner copies result strings into temporary `c_allocator` storage and frees +the original Chapel allocation before completing a request, including +allocation-error paths. The public Zig API then clones those temporaries with +the caller's supplied allocator on the caller thread; that allocator never runs +on the runtime owner. Temporary copies are freed on success or failure, and +partial caller-side copies are freed if a later allocation fails. Rust receives +independent copies and keeps its existing `echidna_free_*` obligation. + +Normal process exit requires callers to have completed their requests. The +exit handler atomically stops admission and joins/finalizes only an idle owner. +If a request or another shutdown is active, it refuses to join and aborts the +process. A fatal Chapel task can call `exit` while its owner waits for the same +task; draining from that unknown exit thread would create a join cycle. The +explicit owner shutdown primitive still drains accepted work and joins, but +the process-exit hook cannot assume that calling context. This follows the pinned Chapel sources: https://github.com/chapel-lang/chapel/blob/2.8.0/runtime/src/tasks/qthreads/tasks-qthreads.c[`chpl_task_init`] @@ -48,7 +57,16 @@ creates and releases the task-local dynamic end-count. The public https://github.com/chapel-lang/chapel/blob/2.8.0/doc/rst/technotes/libraries.rst[library API] requires initialization before exports and finalization after them. The bridge -keeps the installed task runtime and its assertions intact. +keeps the installed task runtime and its assertions intact. The failed-exit +case follows +https://github.com/chapel-lang/chapel/blob/2.8.0/runtime/src/error.c[`chpl_error`] +through +https://github.com/chapel-lang/chapel/blob/2.8.0/runtime/src/chplexit.c[`chpl_exit_any`] +to process exit; in +https://github.com/chapel-lang/chapel/blob/2.8.0/runtime/src/comm/none/comm-none.c[comm/none], +that path does not first shut down all tasks. The guard qualifies a finite +in-flight-export boundary; no full Chapel fatal-task execution receipt is +claimed by the source change. The installed `echidna_chapel_ffi.mode` records real versus stub build intent. That metadata is not an attestation of artifact contents. @@ -59,13 +77,14 @@ The Rust strategy remains synchronous. It accepts positive whole-second per-prover timeouts representable by the C ABI and rejects zero, fractional, or overflowing durations before entering FFI; those values must not silently select Chapel's native default timeout. The native C API retains its documented -zero-means-default convention. +zero-means-default convention. Public Zig timeouts above the signed C range +and negative C timeouts are refused before runtime admission. Queueing, runtime initialization, and availability checks are outside that per-prover timer. This repair does not provide a hard whole-call deadline or safe asynchronous cancellation of an in-process Chapel export. A caller must -not terminate the owner thread. Normal shutdown drains accepted work before -finalization. The existing speculative-search loser cancellation remains a +not terminate the owner thread, and must finish outstanding calls before normal +process exit. The existing speculative-search loser cancellation remains a separate Chapel operation; this bridge continues to invoke best-of search. == Build and checks @@ -107,9 +126,18 @@ separate deadline and reaps a stuck test process. Neither fixture is proof evidence. The Zig test target also exercises actual OS threads with an instrumented -runtime: one initialization/finalization thread, concurrent callers, shutdown -while accepted work is in flight, refusal after shutdown, and callback -re-entry refusal. These controls do not substitute for the real Chapel tests. +runtime: one initialization/finalization thread, concurrent callers, explicit +shutdown while accepted work is in flight, refusal after shutdown, callback +re-entry refusal, and prompt refusal of a nonowner process-exit request while +work is active. The last control releases its held work after a finite wait so +a regressed join can be cleaned up and reported as failure. + +Transfer controls use the production transfer helpers, a real OS owner producing +fixture results, and a recording allocator that refuses foreign-thread +allocation. They cover successful version/result copies, first and partial +allocation failures, and invalid timeout refusal before owner startup. These +fixtures qualify that boundary behavior when executed; they do not substitute +for the real Chapel tests. The existing CI waiver and its expiry are unchanged. A source patch, stub success, or separate Chapel smoke target does not establish that this complete diff --git a/src/zig_ffi/chapel_bridge.zig b/src/zig_ffi/chapel_bridge.zig index 794ef94a..807c23fe 100644 --- a/src/zig_ffi/chapel_bridge.zig +++ b/src/zig_ffi/chapel_bridge.zig @@ -57,14 +57,14 @@ fn registerChapelShutdown() void { } fn shutdownChapel() callconv(.c) void { - chapel_owner.shutdown() catch { - // A fatal exit inside Chapel could invoke atexit on its own worker. - // Recursive finalization/self-join is unsafe; retain a failed process. + chapel_owner.shutdownForProcessExit() catch { + // A fatal Chapel task can call exit while the owner waits for that task. + // Never join active work from an unknown exit thread: fail the process. std.process.abort(); }; } -fn callOnChapelOwner(comptime function: anytype, args: anytype, result: anytype) runtime_owner.CallError!void { +fn callOnOwner(owner: anytype, comptime function: anytype, args: anytype, result: anytype) runtime_owner.CallError!void { const Context = struct { arguments: @TypeOf(args), result: @TypeOf(result), @@ -75,14 +75,17 @@ fn callOnChapelOwner(comptime function: anytype, args: anytype, result: anytype) } }; var context = Context{ .arguments = args, .result = result }; + var work = runtime_owner.Work{ .context = &context, .run = Context.run }; + try owner.call(&work); +} + +fn callOnChapelOwner(comptime function: anytype, args: anytype, result: anytype) runtime_owner.CallError!void { if (options.chapel_stubs) { - Context.run(&context); + result.* = @call(.auto, function, args); return; } - chapel_shutdown_once.call(); - var work = runtime_owner.Work{ .context = &context, .run = Context.run }; - try chapel_owner.call(&work); + try callOnOwner(&chapel_owner, function, args, result); } //============================================================================== @@ -244,6 +247,17 @@ pub const ProofResult = struct { return result; } + // Transfer a temporary result into the caller's allocator on its thread. + fn clone(self: ProofResult, allocator: std.mem.Allocator) !ProofResult { + var copy = self; + copy.prover_name = null; + copy.error_message = null; + errdefer copy.deinit(allocator); + if (self.prover_name) |name| copy.prover_name = try allocator.dupe(u8, name); + if (self.error_message) |message| copy.error_message = try allocator.dupe(u8, message); + return copy; + } + /// Free owned strings pub fn deinit(self: *ProofResult, allocator: std.mem.Allocator) void { if (self.prover_name) |name| { @@ -260,6 +274,7 @@ pub const ProofResult = struct { /// Error types for FFI operations pub const ChapelError = error{ InvalidGoal, + InvalidTimeout, ChapelRuntimeUnavailable, ProofSearchFailed, OutOfMemory, @@ -284,12 +299,31 @@ fn isChapelAvailableOnOwner() bool { /// Get Chapel metalayer version string pub fn getChapelVersion(allocator: std.mem.Allocator) ![]const u8 { - var result: anyerror![]const u8 = undefined; - try callOnChapelOwner(getChapelVersionOnOwner, .{allocator}, &result); - return result; + return getChapelVersionUsing(NativeBridge{}, allocator); } -fn getChapelVersionOnOwner(allocator: std.mem.Allocator) ![]const u8 { +fn getChapelVersionUsing(bridge: anytype, allocator: std.mem.Allocator) ![]const u8 { + const temporary = try bridge.version(); + defer std.heap.c_allocator.free(temporary); + return allocator.dupe(u8, temporary); +} + +const NativeBridge = struct { + fn version(_: @This()) ![]const u8 { + var result: anyerror![]const u8 = undefined; + try callOnChapelOwner(getChapelVersionOnOwner, .{}, &result); + return result; + } + + fn search(_: @This(), goal: []const u8, provers: ?[]const ProverKind, timeout_secs: u32) !ProofResult { + var result: anyerror!ProofResult = undefined; + try callOnChapelOwner(parallelProofSearchOnOwner, .{ goal, provers, timeout_secs }, &result); + return result; + } +}; + +fn getChapelVersionOnOwner() ![]const u8 { + const allocator = std.heap.c_allocator; const c_version = chapel.chapel_get_version(); if (c_version == null) { return error.ChapelRuntimeUnavailable; @@ -343,17 +377,28 @@ pub fn parallelProofSearch( timeout_secs: u32, allocator: std.mem.Allocator, ) !ProofResult { - var result: anyerror!ProofResult = undefined; - try callOnChapelOwner(parallelProofSearchOnOwner, .{ goal, provers, timeout_secs, allocator }, &result); - return result; + return parallelProofSearchUsing(NativeBridge{}, goal, provers, timeout_secs, allocator); } -fn parallelProofSearchOnOwner( +fn parallelProofSearchUsing( + bridge: anytype, goal: []const u8, provers: ?[]const ProverKind, timeout_secs: u32, allocator: std.mem.Allocator, ) !ProofResult { + if (timeout_secs > std.math.maxInt(c_int)) return error.InvalidTimeout; + var temporary = try bridge.search(goal, provers, timeout_secs); + defer temporary.deinit(std.heap.c_allocator); + return temporary.clone(allocator); +} + +fn parallelProofSearchOnOwner( + goal: []const u8, + provers: ?[]const ProverKind, + timeout_secs: u32, +) !ProofResult { + const allocator = std.heap.c_allocator; if (!isChapelAvailableOnOwner()) return error.ChapelRuntimeUnavailable; // Convert goal to C string @@ -414,6 +459,18 @@ export fn echidna_prove_parallel( timeout_secs: c_int, ) CProofResultForRust { const allocator = std.heap.c_allocator; + if (timeout_secs < 0) { + return .{ + .success = 0, + .prover_id = -1, + .time_seconds = 0.0, + .exit_code = -2, + .tactic_count = 0, + .category = -1, + .prover_name = null, + .error_message = allocator.dupeZ(u8, "Invalid negative timeout") catch null, + }; + } const goal = std.mem.span(goal_cstr); @@ -440,7 +497,7 @@ export fn echidna_prove_parallel( } defer if (prover_list) |list| allocator.free(list); - const timeout: u32 = if (timeout_secs > 0) @intCast(timeout_secs) else 0; + const timeout: u32 = @intCast(timeout_secs); // Call Chapel via Zig bridge var result = parallelProofSearch(goal, prover_list, timeout, allocator) catch { @@ -557,3 +614,233 @@ test "stub_chapel_unavailable" { try std.testing.expectEqual(false, isChapelAvailable()); try std.testing.expectEqual(@as(u32, 0), availableProverCount()); } + + +// This allocator is deliberately bound to its creating thread. Returning null +// on foreign-thread allocation makes the previous owner-side clone observable. +const CallerAllocator = struct { + thread_id: std.Thread.Id, + wrong_thread: bool = false, + callbacks: usize = 0, + allocations: usize = 0, + frees: usize = 0, + live_bytes: usize = 0, + fail_after: usize = std.math.maxInt(usize), + + fn init(fail_after: usize) @This() { + return .{ .thread_id = std.Thread.getCurrentId(), .fail_after = fail_after }; + } + + fn allocator(self: *@This()) std.mem.Allocator { + return .{ .ptr = self, .vtable = &.{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = free, + } }; + } + + fn record(self: *@This()) bool { + self.callbacks += 1; + if (self.thread_id != std.Thread.getCurrentId()) { + self.wrong_thread = true; + return false; + } + return true; + } + + fn alloc(raw: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 { + const self: *@This() = @ptrCast(@alignCast(raw)); + if (!self.record() or self.allocations >= self.fail_after) return null; + const pointer = std.heap.c_allocator.rawAlloc(len, alignment, ret_addr) orelse return null; + self.allocations += 1; + self.live_bytes += len; + return pointer; + } + + fn resize(raw: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) bool { + const self: *@This() = @ptrCast(@alignCast(raw)); + _ = self.record(); + _ = memory; + _ = alignment; + _ = new_len; + _ = ret_addr; + return false; + } + + fn remap(raw: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) ?[*]u8 { + const self: *@This() = @ptrCast(@alignCast(raw)); + _ = self.record(); + _ = memory; + _ = alignment; + _ = new_len; + _ = ret_addr; + return null; + } + + fn free(raw: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret_addr: usize) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + _ = self.record(); + // The backing allocator is cross-thread safe so a failed assertion + // never requires corrupting memory or leaking the test allocation. + std.heap.c_allocator.rawFree(memory, alignment, ret_addr); + self.frees += 1; + self.live_bytes -= memory.len; + } +}; + +// The production transfer helpers above receive a completed, c_allocator-owned +// result from this real OS owner. The fixture is not a Chapel runtime receipt. +const TransferFixture = struct { + const Runtime = struct { + initialized: usize = 0, + finalized: usize = 0, + + pub fn initialize(self: *@This()) void { + self.initialized += 1; + } + + pub fn finalize(self: *@This()) void { + self.finalized += 1; + } + }; + + owner: runtime_owner.Owner(Runtime) = runtime_owner.Owner(Runtime).init(.{}), + submissions: usize = 0, + producer_thread: std.Thread.Id = 0, + requested_provers: usize = 0, + requested_timeout: u32 = 0, + + fn version(self: *@This()) ![]const u8 { + self.submissions += 1; + var result: anyerror![]const u8 = undefined; + try callOnOwner(&self.owner, produceVersion, .{self}, &result); + return result; + } + + fn produceVersion(self: *@This()) ![]const u8 { + self.producer_thread = std.Thread.getCurrentId(); + return std.heap.c_allocator.dupe(u8, "recorded owner version"); + } + + fn search(self: *@This(), goal: []const u8, provers: ?[]const ProverKind, timeout_secs: u32) !ProofResult { + _ = goal; + self.submissions += 1; + var result: anyerror!ProofResult = undefined; + try callOnOwner(&self.owner, produceResult, .{ self, provers, timeout_secs }, &result); + return result; + } + + fn produceResult(self: *@This(), provers: ?[]const ProverKind, timeout_secs: u32) !ProofResult { + self.producer_thread = std.Thread.getCurrentId(); + self.requested_provers = if (provers) |ids| ids.len else 0; + self.requested_timeout = timeout_secs; + const allocator = std.heap.c_allocator; + var result = ProofResult{ + .success = false, + .prover = .Z3, + .prover_name = null, + .time_seconds = 0.25, + .exit_code = -1, + .tactic_count = 0, + .error_message = null, + .category = .Smt, + }; + errdefer result.deinit(allocator); + result.prover_name = try allocator.dupe(u8, "fixture prover"); + result.error_message = try allocator.dupe(u8, "fixture failure"); + return result; + } +}; + +test "owner results are cloned and freed using only caller-thread allocator callbacks" { + var fixture = TransferFixture{}; + defer fixture.owner.shutdown() catch unreachable; + var caller_allocator = CallerAllocator.init(std.math.maxInt(usize)); + const allocator = caller_allocator.allocator(); + + { + const version = try getChapelVersionUsing(&fixture, allocator); + defer allocator.free(version); + try std.testing.expectEqualStrings("recorded owner version", version); + } + { + var result = try parallelProofSearchUsing(&fixture, "True", &.{.Z3}, 1, allocator); + defer result.deinit(allocator); + try std.testing.expect(!result.success); + try std.testing.expectEqualStrings("fixture prover", result.prover_name.?); + try std.testing.expectEqualStrings("fixture failure", result.error_message.?); + } + try fixture.owner.shutdown(); + + try std.testing.expect(fixture.producer_thread != caller_allocator.thread_id); + try std.testing.expect(!caller_allocator.wrong_thread); + try std.testing.expectEqual(@as(usize, 3), caller_allocator.allocations); + try std.testing.expectEqual(caller_allocator.allocations, caller_allocator.frees); + try std.testing.expectEqual(@as(usize, 0), caller_allocator.live_bytes); + try std.testing.expectEqual(@as(usize, 1), fixture.requested_provers); + try std.testing.expectEqual(@as(usize, 1), fixture.owner.runtime.initialized); + try std.testing.expectEqual(@as(usize, 1), fixture.owner.runtime.finalized); +} + +test "caller-side first and partial copy failures release every supplied allocation" { + var fixture = TransferFixture{}; + defer fixture.owner.shutdown() catch unreachable; + for ([_]usize{ 0, 1 }) |fail_after| { + var caller_allocator = CallerAllocator.init(fail_after); + try std.testing.expectError( + error.OutOfMemory, + parallelProofSearchUsing(&fixture, "True", &.{.Z3}, 1, caller_allocator.allocator()), + ); + try std.testing.expect(fixture.producer_thread != caller_allocator.thread_id); + try std.testing.expect(!caller_allocator.wrong_thread); + try std.testing.expectEqual(fail_after, caller_allocator.allocations); + try std.testing.expectEqual(caller_allocator.allocations, caller_allocator.frees); + try std.testing.expectEqual(@as(usize, 0), caller_allocator.live_bytes); + try std.testing.expectEqual(@as(usize, 1), fixture.requested_provers); + } + var version_allocator = CallerAllocator.init(0); + try std.testing.expectError(error.OutOfMemory, getChapelVersionUsing(&fixture, version_allocator.allocator())); + try std.testing.expect(!version_allocator.wrong_thread); + try std.testing.expectEqual(@as(usize, 1), version_allocator.callbacks); + try std.testing.expectEqual(@as(usize, 0), version_allocator.live_bytes); +} + +test "Zig timeout overflow is refused before owner admission and zero retains default semantics" { + var fixture = TransferFixture{}; + defer fixture.owner.shutdown() catch unreachable; + var caller_allocator = CallerAllocator.init(std.math.maxInt(usize)); + const allocator = caller_allocator.allocator(); + const maximum: u32 = std.math.maxInt(c_int); + try std.testing.expectError( + error.InvalidTimeout, + parallelProofSearchUsing(&fixture, "True", &.{.Z3}, maximum + 1, allocator), + ); + try std.testing.expectEqual(@as(usize, 0), fixture.submissions); + try std.testing.expectEqual(@as(usize, 0), fixture.owner.runtime.initialized); + try std.testing.expectEqual(@as(usize, 0), caller_allocator.callbacks); + + for ([_]u32{ 0, maximum }) |timeout_secs| { + var result = try parallelProofSearchUsing(&fixture, "True", &.{.Z3}, timeout_secs, allocator); + result.deinit(allocator); + try std.testing.expectEqual(timeout_secs, fixture.requested_timeout); + } + try std.testing.expectEqual(@as(usize, 0), caller_allocator.live_bytes); +} + +test "negative C timeouts are rejected instead of selecting the native default" { + for ([_]c_int{ -1, std.math.minInt(c_int) }) |timeout_secs| { + var result = echidna_prove_parallel("True", null, 0, timeout_secs); + defer echidna_free_proof_result(&result); + try std.testing.expectEqual(@as(c_int, 0), result.success); + try std.testing.expectEqual(@as(c_int, -2), result.exit_code); + try std.testing.expectEqualStrings("Invalid negative timeout", std.mem.span(result.error_message.?)); + } + if (options.chapel_stubs) { + var default_result = echidna_prove_parallel("True", null, 0, 0); + defer echidna_free_proof_result(&default_result); + try std.testing.expectEqual(@as(c_int, 0), default_result.success); + try std.testing.expectEqual(@as(c_int, -1), default_result.exit_code); + try std.testing.expect(chapel_owner.worker == null); + } +} diff --git a/src/zig_ffi/runtime_owner.zig b/src/zig_ffi/runtime_owner.zig index 88bd7438..79d9d543 100644 --- a/src/zig_ffi/runtime_owner.zig +++ b/src/zig_ffi/runtime_owner.zig @@ -11,12 +11,12 @@ pub const Work = struct { completed: bool = false, }; -pub const CallError = error{ RuntimeUnavailable, ReentrantCall }; +pub const CallError = error{ RuntimeUnavailable, ReentrantCall, RuntimeBusy }; pub fn Owner(comptime Runtime: type) type { return struct { const Self = @This(); - const State = enum { idle, running, stopping, stopped, failed }; + const State = enum { idle, running, exit_refused, stopping, stopped, failed }; runtime: Runtime, mutex: std.Thread.Mutex = .{}, @@ -61,7 +61,29 @@ pub fn Owner(comptime Runtime: type) type { // owner, and join. Concurrent shutdown callers wait for the same join. // An idle or closed owner is never restarted. pub fn shutdown(self: *Self) CallError!void { + return self.stop(true); + } + + // atexit can run on an unknown Chapel task which the owner is waiting + // for. Joining active work there can deadlock. Close admission and + // refuse instead; the exit handler must turn this refusal into abort. + pub fn shutdownForProcessExit(self: *Self) CallError!void { + return self.stop(false); + } + + fn stop(self: *Self, allow_active: bool) CallError!void { self.mutex.lock(); + if (!allow_active and + (self.pending != null or self.state == .stopping or self.state == .exit_refused)) + { + if (self.state == .running) { + self.state = .exit_refused; + self.work_available.signal(); + self.changed.broadcast(); + } + self.mutex.unlock(); + return error.RuntimeBusy; + } if (self.worker_id == std.Thread.getCurrentId()) { self.mutex.unlock(); return error.ReentrantCall; @@ -82,7 +104,7 @@ pub fn Owner(comptime Runtime: type) type { self.mutex.unlock(); return; }, - .running => {}, + .running, .exit_refused => {}, } self.state = .stopping; @@ -185,7 +207,7 @@ test "one OS thread owns init concurrent work and finalization" { } for (threads[0..started]) |thread| thread.join(); started = 0; - try owner.shutdown(); + try owner.shutdownForProcessExit(); try std.testing.expectEqual(@as(usize, 1), probe.initialized); try std.testing.expectEqual(@as(usize, 1), probe.finalized); @@ -317,3 +339,90 @@ test "shutdown before initialization never starts the runtime" { try std.testing.expectEqual(@as(usize, 0), probe.initialized); try std.testing.expectEqual(@as(usize, 0), probe.finalized); } + + +test "a nonowner process-exit request refuses active work without a join cycle" { + const TestOwner = Owner(*Probe); + var probe = Probe{}; + var owner = TestOwner.init(&probe); + defer owner.shutdown() catch unreachable; + + const Active = struct { + owner: *TestOwner, + mutex: std.Thread.Mutex = .{}, + changed: std.Thread.Condition = .{}, + entered: bool = false, + release: bool = false, + call_failed: bool = false, + exit_done: bool = false, + exit_refused: bool = false, + + fn work(raw: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.mutex.lock(); + self.entered = true; + self.changed.broadcast(); + while (!self.release) self.changed.wait(&self.mutex); + self.mutex.unlock(); + } + + fn call(self: *@This()) void { + var request = Work{ .context = self, .run = work }; + self.owner.call(&request) catch { + self.call_failed = true; + }; + } + + fn processExit(self: *@This()) void { + const refused = if (self.owner.shutdownForProcessExit()) |_| false else |err| err == error.RuntimeBusy; + self.mutex.lock(); + self.exit_refused = refused; + self.exit_done = true; + self.changed.broadcast(); + self.mutex.unlock(); + } + }; + + var active = Active{ .owner = &owner }; + const caller = try std.Thread.spawn(.{}, Active.call, .{&active}); + var caller_joined = false; + defer if (!caller_joined) caller.join(); + defer { + active.mutex.lock(); + active.release = true; + active.changed.broadcast(); + active.mutex.unlock(); + } + + active.mutex.lock(); + while (!active.entered) active.changed.wait(&active.mutex); + active.mutex.unlock(); + var timer = try std.time.Timer.start(); + const exit_thread = try std.Thread.spawn(.{}, Active.processExit, .{&active}); + active.mutex.lock(); + while (!active.exit_done) { + const remaining = std.time.ns_per_s -| timer.read(); + if (remaining == 0) break; + active.changed.timedWait(&active.mutex, remaining) catch break; + } + const promptly_refused = active.exit_done and active.exit_refused; + // A regressed draining exit path can now finish too: the negative control + // fails after cleanup, rather than leaving the test owner blocked forever. + active.release = true; + active.changed.broadcast(); + active.mutex.unlock(); + + exit_thread.join(); + caller.join(); + caller_joined = true; + var refused = Work{ .context = &probe, .run = Probe.work }; + const admission_closed = if (owner.call(&refused)) |_| false else |err| err == error.RuntimeUnavailable; + try owner.shutdown(); + + try std.testing.expect(promptly_refused); + try std.testing.expect(admission_closed); + try std.testing.expect(!active.call_failed); + try std.testing.expectEqual(@as(usize, 0), probe.calls); + try std.testing.expectEqual(@as(usize, 1), probe.finalized); + try std.testing.expectEqual(probe.init_id, probe.finalize_id); +}