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/.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/actions.lock b/.github/workflows/actions.lock index d7165c14..71c7b2ff 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': - - 'metadatastician/standards@087f6bff5135ce3da6f287c83752256ce4a8efc1' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/hypatia-scan.yml': - - 'metadatastician/standards@087f6bff5135ce3da6f287c83752256ce4a8efc1' + - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/idris2-abi-ci.yml': - 'actions/checkout@v7.0.1' '.github/workflows/label-triage.yml': [] @@ -58,7 +57,7 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.3' '.github/workflows/mirror.yml': - - 'metadatastician/standards@087f6bff5135ce3da6f287c83752256ce4a8efc1' + - 'metadatastician/standards@3c00687a5bbd0d881d351db8502417b5d0a8b0b0' '.github/workflows/mvp-smoke.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' @@ -81,16 +80,17 @@ workflows: - 'swatinem/rust-cache@v2.9.3' - 'taiki-e/install-action@v2.87.32' '.github/workflows/scorecard.yml': - - 'metadatastician/standards@087f6bff5135ce3da6f287c83752256ce4a8efc1' + - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/secret-scanner.yml': - - 'metadatastician/standards@087f6bff5135ce3da6f287c83752256ce4a8efc1' - '.github/workflows/security-scan.yml': [] + - 'hyperpolymath/proven-cicd@a12b2b4644db1a1250bc50b58fd24c99f89f564e' + '.github/workflows/security-scan.yml': + - 'hyperpolymath/panic-attack@be6ac46716eaf049570ec4966f4ae4699b319689' '.github/workflows/server-boot-gate.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.3' '.github/workflows/spark-theatre-gate.yml': - - 'metadatastician/standards@087f6bff5135ce3da6f287c83752256ce4a8efc1' + - 'metadatastician/standards@d3e4b485a2af90c92a8e18af0888cd19cb132cd9' '.github/workflows/static-analysis-gate.yml': - 'actions/checkout@v7.0.1' - 'actions/download-artifact@v8.0.2' @@ -99,12 +99,17 @@ 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' - 'mlugg/setup-zig@v2.2.1' dependencies: + 'Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0': + ref: 'v2.9.3' + commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' + owner_id: 580492 + repo_id: 298565987 'actions/attest-build-provenance@v4.2.2': ref: 'v4.2.2' commit: 'sha1-4d101475d8b20a2381f78447822ac1eab6504dd8' @@ -224,34 +229,61 @@ 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 - 'metadatastician/standards@087f6bff5135ce3da6f287c83752256ce4a8efc1': - ref: '087f6bff5135ce3da6f287c83752256ce4a8efc1' - commit: 'sha1-087f6bff5135ce3da6f287c83752256ce4a8efc1' + 'hyperpolymath/panic-attack@be6ac46716eaf049570ec4966f4ae4699b319689': + ref: 'be6ac46716eaf049570ec4966f4ae4699b319689' + commit: 'sha1-be6ac46716eaf049570ec4966f4ae4699b319689' + owner_id: 6759885 + repo_id: 1152434997 + uses: + - 'Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + '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 + 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/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' - - '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/agda-meta-checker.yml b/.github/workflows/agda-meta-checker.yml index 68c1ebc8..301110dc 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,61 +55,33 @@ 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') - 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 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 + run: bash scripts/ci/setup-agda.sh - - name: Install Agda standard library + - name: Type-check meta-checker (PROOF-POLICY §2.1 canonical invocation) 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 - - - name: Type-check meta-checker - if: steps.detect.outputs.relevant == 'true' - run: | - cd meta-checker - echo "$PWD/echidna-meta.agda-lib" >> ~/.agda/libraries - agda --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda - echo "✓ Meta-checker passed with safe / without-K / cubical-compatible; proof assumptions require a separate audit" + 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/chapel-ci.yml b/.github/workflows/chapel-ci.yml index 8c9316f0..abfeff2c 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 @@ -163,26 +171,24 @@ 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). # - # 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 --locked --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 --locked --features chapel -- proof_search 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/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/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 b673c5b9..5869ce30 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -18,4 +18,7 @@ permissions: jobs: governance: - uses: metadatastician/standards/.github/workflows/governance-reusable.yml@087f6bff5135ce3da6f287c83752256ce4a8efc1 # main 2026-11-04, standards#1395 hypatia-ref adb2f41d (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + 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 ea4a25ce..d0f1dd38 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -17,10 +17,15 @@ on: permissions: actions: read contents: read - security-events: write jobs: hypatia: - uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@087f6bff5135ce3da6f287c83752256ce4a8efc1 # main 2026-11-04, standards#1395 hypatia-ref adb2f41d (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + permissions: + actions: read + 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 5672ce63..b5ed9ea2 100644 --- a/.github/workflows/live-provers.yml +++ b/.github/workflows/live-provers.yml @@ -89,64 +89,90 @@ jobs: - name: Provision prover (${{ matrix.backend }}) run: | set -euo pipefail - # Retry apt-get update once on mirror failure before proceeding. - sudo apt-get update -qq || sudo apt-get update -qq --fix-missing + readonly https_protocol='=https' + # 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 -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 -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 ;; - 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 -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 ;; - 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 + 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" + 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" \ + 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. @@ -183,6 +209,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) @@ -201,7 +228,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_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 @@ -210,11 +237,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 \ - 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) @@ -222,7 +245,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_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 @@ -249,7 +272,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_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 @@ -269,7 +292,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_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 @@ -333,13 +356,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 -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 -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 @@ -370,7 +394,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_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 } @@ -472,6 +496,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/mirror.yml b/.github/workflows/mirror.yml index ac87a3ba..c2a07042 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -24,7 +24,7 @@ permissions: jobs: mirror: - uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@087f6bff5135ce3da6f287c83752256ce4a8efc1 # main 2026-11-04, standards#1395 hypatia-ref adb2f41d (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/rust-ci.yml b/.github/workflows/rust-ci.yml index 2b0a2e5b..5a29a7d5 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/scorecard.yml b/.github/workflows/scorecard.yml index 6a165506..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: metadatastician/standards/.github/workflows/scorecard-reusable.yml@087f6bff5135ce3da6f287c83752256ce4a8efc1 # main 2026-11-04, standards#1395 hypatia-ref adb2f41d (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/secret-scanner.yml b/.github/workflows/secret-scanner.yml index aff0d210..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: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@087f6bff5135ce3da6f287c83752256ce4a8efc1 # main 2026-11-04, standards#1395 hypatia-ref adb2f41d (lockfile-bearing ref: actions.lock required by caller-side enforcement, standards#570) + uses: hyperpolymath/proven-cicd/.github/workflows/secret-scanner-reusable.yml@a12b2b4644db1a1250bc50b58fd24c99f89f564e # published source, 2026-11-04 diff --git a/.github/workflows/server-boot-gate.yml b/.github/workflows/server-boot-gate.yml index 3651d5c8..c8921224 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/spark-theatre-gate.yml b/.github/workflows/spark-theatre-gate.yml index 8fb4e0e1..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: metadatastician/standards/.github/workflows/spark-theatre-gate.yml@087f6bff5135ce3da6f287c83752256ce4a8efc1 # main 2026-11-04, standards#1395 hypatia-ref adb2f41d (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/static-analysis-gate.yml b/.github/workflows/static-analysis-gate.yml index 1f34ab8e..a33b0240 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,165 @@ concurrency: cancel-in-progress: true permissions: contents: read +env: + # 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: - # --------------------------------------------------------------------------- - # 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: + overwrite: true + 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: + overwrite: true 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: + overwrite: true 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: + overwrite: true 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 +179,61 @@ 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: + overwrite: true + 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: + overwrite: true 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/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index f72ffe8d..12080ac9 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 @@ -23,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. @@ -41,3 +52,68 @@ 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 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 + 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|current) ;; + *) 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 -C "$subject" diff --binary -- .github/workflows/actions.lock > "$patch" + if [ "$(wc -c < "$patch")" -gt 900000 ]; then + echo "::error::Candidate exceeds the summary budget; this workflow cannot publish it" + exit 2 + fi + { + echo '## Official actions.lock refresh candidate' + echo + 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" + echo '````' + } >> "$GITHUB_STEP_SUMMARY" + cat "$patch" diff --git a/.machine_readable/descriptiles/META.a2ml b/.machine_readable/descriptiles/META.a2ml index 9efc42ba..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] @@ -28,9 +28,14 @@ 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 = 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) +# These are actual binary defaults, not a claim of estate-wide port allocation. # ───────────────────────────────────────────────────────────────────────────── # Saturation campaign 2026-06-01 — additive surface declaration 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/.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/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/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 3919d823..9a6373b3 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) @@ -43,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 8de9b9ef..0bf91e23 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]] @@ -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" @@ -1266,7 +1246,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]] @@ -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" @@ -2163,7 +2093,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]] @@ -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", @@ -3035,7 +2910,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -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" @@ -3408,7 +3251,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]] @@ -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" @@ -3572,7 +3394,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3650,9 +3472,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", @@ -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" @@ -4392,7 +4198,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]] @@ -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/Containerfile b/Containerfile index ea6e3742..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 @@ -40,7 +41,11 @@ 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) @@ -57,19 +62,18 @@ 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) @@ -91,11 +95,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/Justfile b/Justfile index 7e05062e..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 @@ -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 --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 --locked --features chapel + +chapel-test-real: build-chapel-real + 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 @@ -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 ───────────────────────────────── @@ -965,7 +973,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 093bf0b2..9e0eefce 100644 --- a/QUICKSTART-USER.adoc +++ b/QUICKSTART-USER.adoc @@ -133,8 +133,26 @@ 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 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. + +`echidna-graphql` defaults to 8082 and can run alongside the core server +on 8081. Start it with: + +[source,bash] +---- +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`. 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..4752cb45 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*: -** GraphQL (async-graphql, port 8081) +* *Three interface binaries*, alongside the core server: +** 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); 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 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/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..2208710f --- /dev/null +++ b/build_support/chapel.rs @@ -0,0 +1,501 @@ +// 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) +} + +/// 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() { + 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_RUNTIME_LIB", + "CHPL_RUNTIME_INCL", + "CHPL_THIRD_PARTY", + "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 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, + }); + 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::*; + + 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)); + 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 13d75960..bdb5a808 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 @@ -75,6 +80,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/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/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/deploy.k9.ncl b/container/deploy.k9.ncl index 3165f868..cce4d57a 100644 --- a/container/deploy.k9.ncl +++ b/container/deploy.k9.ncl @@ -70,8 +70,12 @@ let deployment = { container = { image = "ghcr.io/hyperpolymath/echidna", port = 8081, - health_check = "/health", - readiness_check = "/ready", + # 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", }, strategy = { type = "rolling", 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/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/ARCHITECTURE.adoc b/docs/ARCHITECTURE.adoc index 58afe77c..75b9dcaf 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,18 @@ 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 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 +the binary you invoked is the binary you pinned. === Component map @@ -31,7 +40,7 @@ independently reproduced where formats allow (Alethe, DRAT/LRAT, TSTP). │ Rust Core (src/rust/, crates/) │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ │ -│ │ CLI/REPL │ │ REST / GraphQL│ │ gRPC │ │ FFI (Zig) │ │ +│ │ CLI/REPL │ │ REST │ │ gRPC │ │ FFI (Zig) │ │ │ │ (main.rs)│ │ (axum :8000) │ │ (tonic :50051)│ │ (16 exports) │ │ │ └────┬─────┘ └──────┬────────┘ └──────┬────────┘ └──────┬───────┘ │ │ └────────────────┴──────────────────┴────────────────┘ │ @@ -103,7 +112,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 +143,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:8082+` |`+POST /+` (GraphQL), `+GET /+` +(playground), `+/health+` +|`+echidna-grpc+` |`+[::1]:50051+` |`+ProofService+` over `+echidna.proto+` +|=== + +`+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 +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..df7ba09e 100644 --- a/docs/DEBT.adoc +++ b/docs/DEBT.adoc @@ -421,6 +421,125 @@ 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. + +==== 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. 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 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 +silently changed. + +[source,bash] +---- +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 + +*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 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 +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. + +[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+` had no in-tree consumer; + 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..47c91a5b 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`) 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. *Command*: @@ -190,5 +199,5 @@ Not created yet ''''' -*Last Updated*: 2026-02-12 *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 62013464..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 @@ -26,6 +26,34 @@ 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. 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",] +|=== +|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:8082+` +|`+src/interfaces/graphql/main.rs+` |Bind address for `+echidna-graphql+`. +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+` +|`+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/HTTP-CLIENT-COMPATIBILITY.adoc b/docs/HTTP-CLIENT-COMPATIBILITY.adoc new file mode 100644 index 00000000..dadd925f --- /dev/null +++ b/docs/HTTP-CLIENT-COMPATIBILITY.adoc @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += 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/PROOF-NEEDS.adoc b/docs/PROOF-NEEDS.adoc index a9143ac5..9aa00127 100644 --- a/docs/PROOF-NEEDS.adoc +++ b/docs/PROOF-NEEDS.adoc @@ -296,3 +296,83 @@ 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 + +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`. +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 + +[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 +|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; 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. + +|`+Receipt.agda+` +|did not exist +|Receipt/warrant separation as a two-world Kripke countermodel: receipts are +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+` +|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) + +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 new file mode 100644 index 00000000..bcf6c700 --- /dev/null +++ b/docs/PROOF-STATUS.adoc @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 ECHIDNA Project Contributors + += Proof status — policy-vocabulary view + +*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/metadatastician/standards[metadatastician/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. 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 --no-default-libraries --library-file "$AGDA_LIBRARIES_FILE" \ + --safe --without-K --cubical-compatible src/Echidna/MetaChecker.agda +---- + +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 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 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. `+≈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+` +|`+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. +|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, 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 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+` +|`+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. + +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 + +* `+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/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/docs/SSG_ROADMAP.adoc b/docs/SSG_ROADMAP.adoc index 29443b41..c7f95b5b 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]. +* https://github.com/hyperpolymath/echidna/pull/455[Current repair review and unresolved alert work]. 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/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 new file mode 100644 index 00000000..e4655fcc --- /dev/null +++ b/docs/reports/2026-11-01-routing-and-rule-engine-recon.adoc @@ -0,0 +1,1012 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 ECHIDNA Project Contributors + += Re-recon: routing surfaces and the rule-engine source + +*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 <>. + +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 `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. + +*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, <>). + +*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 +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 + +* 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. +* 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:8082+` — `ECHIDNA_GRAPHQL_ADDR`; changed 2026-11-04 from the 8081 observed in this recon +|`+/+` (POST = GraphQL, GET = playground), `+/health+` +|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` +|`+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 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. +. 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 8082 (8081 before 2026-11-04). Queries are `+provers+`, + `+proofState(id:)+`, `+listProofs+`, `+suggestTacticsByProofId+`, + `+proverStatus+`, `+health+`; mutations `+submitProof+`, `+applyTactic+`, + `+cancelProof+`, `+verifyProof+`, `+suggestTactics+`. + +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 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 +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/provers+`, `+/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 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 +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. + +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 -A 1 'VarError::NotPresent' 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/+`. +. **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. + +=== 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/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..71d5a820 --- /dev/null +++ b/docs/reports/2026-11-04-agda-meta-checker-receipt.json @@ -0,0 +1,132 @@ +{ + "schema_version": 1, + "spdx_license_id": "CC-BY-SA-4.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/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml b/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml index f8f845f9..0cd4594c 100644 --- a/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml +++ b/echidna-playground/.machine_readable/descriptiles/NEUROSYM.a2ml @@ -4,12 +4,17 @@ # 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 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/fuzz/Cargo.lock b/fuzz/Cargo.lock index 8978540a..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,7 +1865,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -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/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..f8b01b52 --- /dev/null +++ b/meta-checker/src/Echidna/ClampTrust.agda @@ -0,0 +1,154 @@ +-- 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`; 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 +-- 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-idempotent : 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 +------------------------------------------------------------------------ + +-- 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 +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..d724e96d --- /dev/null +++ b/meta-checker/src/Echidna/Integrity.agda @@ -0,0 +1,152 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +------------------------------------------------------------------------ +-- 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 digest-comparison soundness hypothesis load-bearing rather +-- than decorative. +-- +-- 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 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". `≈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 +------------------------------------------------------------------------ + +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 (¬_) + +------------------------------------------------------------------------ +-- 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 +-- 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 + + -- 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)) + + ---------------------------------------------------------------------- + -- 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 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. 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 () + +------------------------------------------------------------------------ +-- Sharpness: the assumption is load-bearing +-- +-- 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 + field + Digest : Set + _≈d_ : Digest → Digest → Bool + +-- A comparator that accepts everything: digest-comparison soundness 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. +sharpness-matters : ¬ (true ≡ false) +sharpness-matters () + +-- Refute precisely the property required by Hash, not merely Bool +-- inequality: any proposed soundness witness makes true equal false. +sharpness-blocks-soundness : + ¬ (∀ {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 c6eb3e72..2fff5bb5 100644 --- a/meta-checker/src/Echidna/MetaChecker.agda +++ b/meta-checker/src/Echidna/MetaChecker.agda @@ -35,8 +35,55 @@ -- ✓ Pipeline is monotonically degrading -- ✓ Pipeline is deterministic -- --- TOTAL: 30+ proven properties, 0 postulates (except funext in AxiomSafety), --- 0 sorry, 0 believe_me, 0 Admitted. +-- 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 +-- ✓ No uniform warrant-to-receipt conversion exists +-- ✓ No receipt exists while the agent cannot discriminate the worlds +-- ✓ 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 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] +-- ✓ 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 sorry, 0 believe_me, +-- 0 Admitted. +-- +-- "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 +-- 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 +-- 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 +92,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 +126,24 @@ key-order-1 = ≤ₜ-refl key-order-2 = ≤ₜ-antisym key-order-3 = ≤ₜ-trans key-order-4 = ≤ₜ-total + +-- 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 in the model +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..be05694a --- /dev/null +++ b/meta-checker/src/Echidna/Receipt.agda @@ -0,0 +1,305 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +------------------------------------------------------------------------ +-- 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 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. 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.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 +-- +-- 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 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 + +------------------------------------------------------------------------ +-- The sharpened frame: excluding the alternative is what buys a receipt +-- +-- 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. 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 + ≈′-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: accessibility changed, while factivity still holds. +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 +-- +-- 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. +------------------------------------------------------------------------ + +-- 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 + em : Axiom + +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 : (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 = ⊤ + +postulates-name-an-axiom : + ∀ {assumptions frame φ w} → + Asserts assumptions frame proven-with-postulates φ w → Axiom +postulates-name-an-axiom = proj₁ + +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 : + ∀ {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/reports/scan-results-echidna.json b/reports/scan-results-echidna.json deleted file mode 100644 index e69de29b..00000000 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..51cf576c --- /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 --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 \ + "$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/install-lean.sh b/scripts/ci/install-lean.sh new file mode 100644 index 00000000..109c9940 --- /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 -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" +"$ELAN_HOME/bin/lean" --version diff --git a/scripts/ci/run-live-prover.sh b/scripts/ci/run-live-prover.sh new file mode 100644 index 00000000..623db343 --- /dev/null +++ b/scripts/ci/run-live-prover.sh @@ -0,0 +1,38 @@ +#!/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 ;; + gpuverify) executable=gpuverify ;; + faial) executable=faial-drf ;; + *) 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/ci/static-analysis-evidence.sh b/scripts/ci/static-analysis-evidence.sh new file mode 100755 index 00000000..41041c5c --- /dev/null +++ b/scripts/ci/static-analysis-evidence.sh @@ -0,0 +1,254 @@ +#!/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() { + local report=$1 + jq -e -s 'length == 1' "$report" >/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" ;; + *) echo "Unknown scanner: $tool" >&2; return 2 ;; + 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 | 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 + 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:?} ;; + *) echo "Unknown scanner: $tool" >&2; return 2 ;; + 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-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/scripts/ci/test-static-analysis-evidence.sh b/scripts/ci/test-static-analysis-evidence.sh new file mode 100755 index 00000000..e2accf98 --- /dev/null +++ b/scripts/ci/test-static-analysis-evidence.sh @@ -0,0 +1,176 @@ +#!/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" +# 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 +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" +[[ ! -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" +[[ ! -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" +[[ ! -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" +[[ ! -e "$tmp/findings/unified-findings.json" ]] +printf '%s receipt controls passed\n' "$passed" diff --git a/scripts/install-proof-toolchains.sh b/scripts/install-proof-toolchains.sh index 088a8317..bf8fd005 100755 --- a/scripts/install-proof-toolchains.sh +++ b/scripts/install-proof-toolchains.sh @@ -11,6 +11,40 @@ # 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 +# 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/ +# +# # 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 +# # 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 +# +# 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, +# 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/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 03735e91..dc468e41 100644 --- a/site/docs/api/graphql.adoc +++ b/site/docs/api/graphql.adoc @@ -1,10 +1,24 @@ == 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: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+`. + +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:8092 ./echidna-graphql +---- + +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 67d96d29..9d61bef0 100644 --- a/site/docs/index.adoc +++ b/site/docs/index.adoc @@ -51,11 +51,14 @@ 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]. +GraphQL and the core server use distinct default ports. Set +`+ECHIDNA_GRAPHQL_ADDR+` to override the GraphQL listener. + ==== 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/chapel/README.adoc b/src/chapel/README.adoc index 21875476..6bd0b1b2 100644 --- a/src/chapel/README.adoc +++ b/src/chapel/README.adoc @@ -1,64 +1,152 @@ -== Chapel Production Integration for ECHIDNA - -Parallel proof search using Chapel’s task parallelism, integrated into -the main ECHIDNA codebase. - -=== Overview - -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. - -=== Integration Status - -* *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+` - -=== Build Instructions - -==== 1. Install Chapel - -[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 - -# Set environment -source util/setchplenv.bash - -# Build (takes ~10 minutes) -make ----- - -==== 2. Build Chapel Code +// SPDX-License-Identifier: MPL-2.0 += Chapel integration for ECHIDNA + +Parallel proof search implemented in Chapel, with a Zig bridge consumed by +the optional Rust `chapel` feature. + +== Current build boundary + +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 +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. + +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 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`] +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 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. + +=== 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. 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, 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 + +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] ---- -cd src/chapel -chpl parallel_proof_search.chpl -o proof_search ----- +# Real static library, bridge, and Rust feature. +just build-chapel-real +just chapel-test-real -==== 3. Build Zig FFI Bridge +# Includes the standalone Chapel smoke target and independent stub tests. +just chapel-all -[source,bash] ----- -cd src/zig_ffi -zig build -Dstubs=false ----- - -==== 4. Build Rust with Chapel Feature - -[source,bash] ----- -cargo build --features chapel +# Deliberate unavailable-stub fallback only. +just build-chapel +just chapel-test ---- -=== Expected Output +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. + +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 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, 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 +native path has passed. + +=== Historical example 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 +191,7 @@ Speedup: 1.51x ✓ Parallel search is significantly faster! .... -=== What This Demonstrates +=== Intended behavior illustrated above ==== 1. Parallel Proof Search @@ -113,7 +201,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 +246,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/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/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 975f3a2b..48225510 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}; @@ -13,14 +14,36 @@ 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:8082` when it is absent. +/// 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 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(); + let echidna_ctx = EchidnaContext::new()?; // Build GraphQL schema with ECHIDNA context let schema = Schema::build(QueryRoot, MutationRoot, EmptySubscription) @@ -36,12 +59,13 @@ async fn main() { .route("/health", get(health_check)) .layer(CorsLayer::permissive()); - let addr = "127.0.0.1:8081"; 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/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/graphql/tests/startup.rs b/src/interfaces/graphql/tests/startup.rs new file mode 100644 index 00000000..002987d1 --- /dev/null +++ b/src/interfaces/graphql/tests/startup.rs @@ -0,0 +1,169 @@ +// 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 + ); +} + +#[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/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..58d1190c 100644 --- a/src/rust/ipc/mod.rs +++ b/src/rust/ipc/mod.rs @@ -76,15 +76,16 @@ impl Transport { match self { Transport::Uds { path } => path.exists(), Transport::Tcp { host, port } => { - use std::net::TcpStream; + use std::net::{TcpStream, ToSocketAddrs}; 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() + // 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; + }; + addresses.any(|address| { + TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() + }) }, } } @@ -179,6 +180,94 @@ 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_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()); + } + + 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 = 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(), + 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/proof_search.rs b/src/rust/proof_search.rs index de39515c..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 _); } @@ -220,14 +232,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 +265,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 +274,245 @@ mod chapel_ffi { } } } + #[cfg(test)] + 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() { + 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 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; + } + + // 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 mut command = real_probe_command( + "real_search_without_provers_fails_and_frees_result", + CHILD, + ); + 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", + ); + } + } } #[cfg(feature = "chapel")] diff --git a/src/rust/provers/altergo.rs b/src/rust/provers/altergo.rs index 2f887be7..9dc224f9 100644 --- a/src/rust/provers/altergo.rs +++ b/src/rust/provers/altergo.rs @@ -96,11 +96,12 @@ 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")?; + 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/chuffed.rs b/src/rust/provers/chuffed.rs index 96808cae..879e33ba 100644 --- a/src/rust/provers/chuffed.rs +++ b/src/rust/provers/chuffed.rs @@ -43,7 +43,24 @@ 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 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); + 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?; @@ -216,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")); + } } diff --git a/src/rust/provers/cvc5.rs b/src/rust/provers/cvc5.rs index dd8a14c8..11c19608 100644 --- a/src/rust/provers/cvc5.rs +++ b/src/rust/provers/cvc5.rs @@ -534,10 +534,12 @@ 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")?; + 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 716198d3..d899089a 100644 --- a/src/rust/provers/eprover.rs +++ b/src/rust/provers/eprover.rs @@ -84,11 +84,12 @@ 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")?; + 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/faial.rs b/src/rust/provers/faial.rs index 3457cf5a..1983cdf6 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,197 @@ 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)] + 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.binary().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("--version timed out")); + } + #[test] fn kind_is_faial() { let backend = FaialBackend::new(ProverConfig::default()); 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/glpk.rs b/src/rust/provers/glpk.rs index fcfed149..f0c42f91 100644 --- a/src/rust/provers/glpk.rs +++ b/src/rust/provers/glpk.rs @@ -40,10 +40,10 @@ 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?; + 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/gpuverify.rs b/src/rust/provers/gpuverify.rs index ab95282b..f994ce82 100644 --- a/src/rust/provers/gpuverify.rs +++ b/src/rust/provers/gpuverify.rs @@ -148,20 +148,30 @@ 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 +375,197 @@ 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)] + 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.binary().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("--version timed out")); + } + #[test] fn kind_is_gpuverify() { let backend = GpuVerifyBackend::new(ProverConfig::default()); 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/minizinc.rs b/src/rust/provers/minizinc.rs index 3c056b43..28f127e9 100644 --- a/src/rust/provers/minizinc.rs +++ b/src/rust/provers/minizinc.rs @@ -46,10 +46,10 @@ 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?; + 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/mod.rs b/src/rust/provers/mod.rs index a9e78bd3..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; @@ -216,8 +217,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 +244,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 +252,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..e439330e 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,36 @@ 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.clamp(1, 30)), + Command::new(&self.config.executable) + .arg("--version") + .stdin(Stdio::null()) + .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 +206,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/provers/vampire.rs b/src/rust/provers/vampire.rs index a424ff79..c33ebf74 100644 --- a/src/rust/provers/vampire.rs +++ b/src/rust/provers/vampire.rs @@ -83,11 +83,12 @@ 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")?; + 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 new file mode 100644 index 00000000..081e88dd --- /dev/null +++ b/src/rust/provers/version_probe.rs @@ -0,0 +1,409 @@ +// 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}"), + } + } + + 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( + "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..19fac7cc 100644 --- a/src/rust/provers/z3.rs +++ b/src/rust/provers/z3.rs @@ -233,11 +233,12 @@ 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")?; + 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()) 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/src/zig_ffi/build.zig b/src/zig_ffi/build.zig index 122934f2..ec861372 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,22 @@ 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); + + // 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 10d63473..807c23fe 100644 --- a/src/zig_ffi/chapel_bridge.zig +++ b/src/zig_ffi/chapel_bridge.zig @@ -10,6 +10,8 @@ // Chapel (C API) → Zig (@cImport) → Rust (safe calls) const std = @import("std"); +const options = @import("chapel_options"); +const runtime_owner = @import("runtime_owner.zig"); //============================================================================== // Import Chapel's C API @@ -17,8 +19,75 @@ const std = @import("std"); const chapel = @cImport({ @cInclude("chapel_ffi_exports.h"); + @cInclude("stdlib.h"); }); +// 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 }; + + 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; + + pub fn initialize(_: *@This()) void { + chpl_library_init(1, &arguments); + chpl__init_parallel_proof_search(0, 0); + chpl__init_chapel_ffi_exports(0, 0); + } + + pub fn finalize(_: *@This()) void { + chpl_library_finalize(); + } +}; + +var chapel_owner = runtime_owner.Owner(ChapelRuntime).init(.{}); +var chapel_shutdown_once = std.once(registerChapelShutdown); + +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.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 callOnOwner(owner: anytype, 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 }; + 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) { + result.* = @call(.auto, function, args); + return; + } + chapel_shutdown_once.call(); + try callOnOwner(&chapel_owner, function, args, result); +} + //============================================================================== // Core Types — all 30 prover backends //============================================================================== @@ -151,6 +220,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)) { @@ -177,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| { @@ -193,6 +274,7 @@ pub const ProofResult = struct { /// Error types for FFI operations pub const ChapelError = error{ InvalidGoal, + InvalidTimeout, ChapelRuntimeUnavailable, ProofSearchFailed, OutOfMemory, @@ -206,12 +288,42 @@ pub const ChapelError = error{ /// Check if Chapel runtime is available pub fn isChapelAvailable() bool { - 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 { + return getChapelVersionUsing(NativeBridge{}, allocator); +} + +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; @@ -223,12 +335,24 @@ pub fn getChapelVersion(allocator: std.mem.Allocator) ![]const u8 { /// Get count of provers available on this system (0-30) pub fn availableProverCount() u32 { + 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 { + 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; } @@ -253,10 +377,29 @@ pub fn parallelProofSearch( timeout_secs: u32, allocator: std.mem.Allocator, ) !ProofResult { - // Validate Chapel is available - if (!isChapelAvailable()) { - return error.ChapelRuntimeUnavailable; - } + return parallelProofSearchUsing(NativeBridge{}, goal, provers, timeout_secs, allocator); +} + +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 const goal_cstr = try allocator.dupeZ(u8, goal); @@ -285,13 +428,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); } //============================================================================== @@ -319,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); @@ -345,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 { @@ -411,8 +563,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; } @@ -462,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 new file mode 100644 index 00000000..79d9d543 --- /dev/null +++ b/src/zig_ffi/runtime_owner.zig @@ -0,0 +1,428 @@ +// 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, RuntimeBusy }; + +pub fn Owner(comptime Runtime: type) type { + return struct { + const Self = @This(); + const State = enum { idle, running, exit_refused, 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 { + 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; + } + + 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, .exit_refused => {}, + } + + 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.shutdownForProcessExit(); + + 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); +} + + +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); +} 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" } 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/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); 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());