Skip to content

Commit e4fba04

Browse files
ArghDA M7: Echidna dispatch seam — completes the engine-spine deliverable (#40)
## What & why The last piece of the engine-spine deliverable (**M0–M7 + M11**). A thin indirection so a backend's check can route **locally** (default, real) or — in future — to the **Echidna** multi-prover orchestrator (Zig-API, port 8090), returning the *same* `Outcome` contract either way. - **`src/dispatch.rs`** — `Dispatch { Local, Echidna { base_url } }` + `run(backend, file, include_root) -> Outcome`. `Local` delegates to `backend.check_file`. `Dispatch::parse` accepts `local` / `echidna` / `echidna=<url>`. - **`src/main.rs`** — `--dispatch local|echidna[=<url>]` wired on `check` and `reason --check`. ## Honest-stub discipline Echidna's orchestrator API is unconfirmed (it may not exist yet) and this seam explicitly does not block on it. Rather than invent an unverifiable HTTP contract and risk fabricating a verdict, the Echidna route returns `available: false` / `Unavailable` with a clear reason — **never a made-up result** (same discipline as cicd-squabbler's "exits EX_UNAVAILABLE rather than fake OK"). The value delivered now is the *seam*: one indirection point with the `Outcome` contract preserved, so wiring the real (feature-gated) client later touches only `Dispatch::run` — no backend or caller changes. ## Verified (ran, not inferred) - `cargo test` → **124 passing, 0 failed** (+3 dispatch unit: parse; echidna-never-fabricates; local-delegates). - clippy -D warnings, fmt, SPDX clean. - **Dogfooded:** `--dispatch local` z3 unsat → `proven-eligible`; `--dispatch echidna` → `backend-unavailable` (honest); `--dispatch bogus` → clean error. ## Milestone status — engine spine complete **M0–M7 + M11 all landed; the v0.1 spec is closed.** Six backends across both interaction models, the honest reasoning graph, doctor, Groove discovery, and the dispatch seam. STATE bumped to 92% / beta. Remaining is refinement (M3/M4/M6/M7 follow-ons) and the heavy-prover tail (M8–M10, `--heavy`). --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2f4c14 commit e4fba04

5 files changed

Lines changed: 237 additions & 17 deletions

File tree

.github/workflows/codeql.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
name: CodeQL Security Analysis
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
branches: [main, master]
8+
schedule:
9+
- cron: '0 6 1 * *' # monthly 1st 06:00 UTC (Actions burn cut, standards#288)
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
permissions:
14+
contents: read
15+
jobs:
16+
analyze:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 15
19+
permissions:
20+
contents: read
21+
security-events: write
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
include:
26+
# Default to `actions` — scaffolded repos rarely have JS/TS
27+
# source; `javascript-typescript` produced "no source files"
28+
# failures on every CodeQL run. The `actions` extractor scans
29+
# workflow files which every repo has. Override per-repo if
30+
# the scaffolded project actually contains JS/TS code.
31+
# Per hypatia rule `codeql_language_matrix_mismatch`.
32+
- language: actions
33+
build-mode: none
34+
steps:
35+
- name: Checkout
36+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
37+
- name: Initialize CodeQL
38+
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.28.1
39+
with:
40+
languages: ${{ matrix.language }}
41+
build-mode: ${{ matrix.build-mode }}
42+
- name: Perform CodeQL Analysis
43+
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.28.1
44+
with:
45+
category: "/language:${{ matrix.language }}"

.machine_readable/6a2/STATE.a2ml

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ status = "active" # active | paused | archived
1111

1212
[project-context]
1313
name = "arghda-core"
14-
purpose = "Lightweight proof-workspace manager for Agda — the language-agnostic engine half of arghda. A four-state triage workspace (inbox/working/proven/rejected) as file moves, a lint pack targeting the silent-failure class, a first-class import-graph DAG, and a JSON + event-stream contract a visual layer (arghda-studio) consumes. It never proves anything; Agda does."
15-
completion-percentage = 85
14+
purpose = "Multi-backend proof-workspace manager — the 'Flying Logic for provers/solvers' engine. A four-state triage workspace (inbox/working/proven/rejected) as file moves, per-backend lint packs targeting the silent-failure class, a backend-parametric import-graph DAG, a demote-only status-propagating reasoning graph, and a JSON + event-stream contract a visual layer (arghda-studio) consumes. Six backends across both interaction models (Agda, Cubical-Agda, Idris2, Lean4 assistants; Z3, CVC5 solvers). It never proves anything itself; the tools do."
15+
completion-percentage = 92
1616

1717
[position]
1818
phase = "implementation" # design | implementation | testing | maintenance | archived
19-
maturity = "alpha" # experimental | alpha | beta | production | lts
19+
maturity = "beta" # experimental | alpha | beta | production | lts
2020

2121
[route-to-mvp]
2222
milestones = [
@@ -47,7 +47,7 @@ milestones = [
4747
{ name = "M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; escape-hatches)", completion = 90 },
4848
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 100 },
4949
{ name = "M6: Lean4 adapter (lake build + #print axioms audit)", completion = 85 },
50-
{ name = "M7: Echidna dispatch seam (optional route to orchestrator :8090, same Outcome)", completion = 0 },
50+
{ name = "M7: Echidna dispatch seam (optional route to orchestrator :8090, same Outcome)", completion = 70 },
5151
{ name = "M8: Coq/Rocq adapter (--heavy provisioning; Section-aware postulate classifier)", completion = 0 },
5252
{ name = "M9: Isabelle adapter (--heavy; dogfood tropical-resource-typing .thy)", completion = 0 },
5353
{ name = "M10: Mizar adapter (detect-only first; highest uncertainty)", completion = 0 },
@@ -60,6 +60,22 @@ milestones = [
6060
# Remaining M0: `arghda doctor` Rust subcommand (interim: `just doctor` shells
6161
# the script's --verify-only table).
6262
#
63+
# M7 landed 2026-07-01 (70%): the Echidna dispatch seam — completes the
64+
# engine-spine deliverable (M0-M7 + M11). src/dispatch.rs: `Dispatch { Local,
65+
# Echidna { base_url } }` + `run(backend, file, include_root) -> Outcome`
66+
# preserving the SAME Outcome contract on both routes. Local = fully real
67+
# (delegates to backend.check_file). Echidna = an HONEST STUB: Echidna's
68+
# orchestrator API is unconfirmed (may not exist), and the seam explicitly does
69+
# NOT block on it, so rather than invent an unverifiable HTTP contract and risk
70+
# fabricating a verdict, the Echidna route returns available:false / Unavailable
71+
# with a clear reason (mirrors cicd-squabbler's "exits EX_UNAVAILABLE rather than
72+
# fake OK" precedent). Wired via `--dispatch local|echidna[=<url>]` on `check`
73+
# and `reason --check`. 124 tests (+3 dispatch unit: parse; echidna-never-
74+
# fabricates; local-delegates). Dogfooded: local z3 unsat→proven-eligible;
75+
# echidna→backend-unavailable (honest); bogus→clean error. Follow-on (the 30%):
76+
# the real orchestrator HTTP client, feature-gated, once Echidna's API is
77+
# confirmed — only Dispatch::run changes (no backend/caller churn).
78+
#
6379
# M11 landed 2026-07-01: studio JSON freeze (reason/0.1) + Groove manifest —
6480
# closes the ONE open v0.1 milestone (Groove service manifest, was 0%). Two
6581
# deliverables. (1) Studio JSON freeze: a reason/0.1 JSON CONTRACT test in
@@ -200,12 +216,12 @@ milestones = [
200216

201217
[critical-next-actions]
202218
actions = [
203-
"M0-M6 + M11 DONE. The v0.1 spec is now CLOSED (Groove manifest was the last open milestone). Engine-spine deliverable (M0-M7 + M11) is complete bar M7. Six backends + reasoning graph + doctor + groove. Remaining:",
204-
"Flying-Logic epic M7: Echidna dispatch seam (feature-gated client, same Outcome) — completes the engine-spine deliverable.",
219+
"ENGINE-SPINE DELIVERABLE COMPLETE: M0-M7 + M11 all landed. v0.1 spec closed. Six backends (agda, agda-cubical, idris2, lean4 Assistant; z3, cvc5 Solver) + reasoning graph + doctor + groove + dispatch seam. Remaining work is refinement + the heavy tail:",
220+
"M7 follow-on: the real Echidna orchestrator HTTP client (feature-gated), once Echidna's API is confirmed — only Dispatch::run changes.",
205221
"M6 follow-on: lake-env/LEAN_PATH multi-file resolution; per-decl #print axioms audit to promote Unknown→Proven.",
206222
"M4 follow-on: .ipkg-declared roots; totality-hole (?name) + per-def `partial` lint.",
207223
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs).",
208-
"M8-M10 heavy tail (Coq/Rocq, Isabelle, Mizar) — gated on --heavy provisioning.",
224+
"M8-M10 heavy tail (Coq/Rocq, Isabelle, Mizar) — gated on --heavy provisioning (Coq via opam ~1hr; Isabelle ~4GB; Mizar niche).",
209225
]
210226

211227
[maintenance-status]
@@ -214,11 +230,11 @@ last-result = "pass" # unknown | pass | warn | fail
214230
open-warnings = 0
215231
open-failures = 0
216232
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
217-
# post-M11: 88 lib + 33 integration = 121 tests pass. `arghda doctor`/`groove`
218-
# dogfooded: 6/6 backends runnable; manifest emits + writes /.well-known/groove.
219-
# All six backends dogfooded via real CLI: agda + agda-cubical + idris2 + lean4
220-
# (Assistant), z3 + cvc5 (Solver). Dogfooded historically vs echo-types +
221-
# agda-unused.
233+
# post-M7: 91 lib + 33 integration = 124 tests pass. doctor/groove/dispatch
234+
# dogfooded: 6/6 backends runnable; groove writes /.well-known/groove; --dispatch
235+
# local real, echidna honest-stub. All six backends dogfooded via real CLI: agda
236+
# + agda-cubical + idris2 + lean4 (Assistant), z3 + cvc5 (Solver). Dogfooded
237+
# historically vs echo-types + agda-unused.
222238

223239
[ecosystem]
224240
part-of = ["arghda"]

src/dispatch.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
4+
//! The Echidna dispatch seam.
5+
//!
6+
//! A backend's `check_file` can run one of two ways, both returning the SAME
7+
//! [`Outcome`] contract:
8+
//! * [`Dispatch::Local`] — shell out to the tool directly (the default, and
9+
//! the fully-real baseline).
10+
//! * [`Dispatch::Echidna`] — route to the Echidna multi-prover orchestrator
11+
//! (Zig-API, port 8090) which runs the check elsewhere and returns the
12+
//! verdict.
13+
//!
14+
//! **Honest-stub discipline.** Echidna's orchestrator repo/API is unconfirmed
15+
//! (it may not exist yet), and this seam explicitly *does not block on Echidna
16+
//! existing*. Rather than invent an unverifiable HTTP contract and risk
17+
//! fabricating a verdict, the Echidna route is an honest stub: it returns an
18+
//! [`Outcome`] with `available: false` / [`Verdict::Unavailable`] and a clear
19+
//! reason — never a made-up result. This mirrors the estate precedent
20+
//! (cicd-squabbler's webhook server "exits EX_UNAVAILABLE rather than fake
21+
//! OK"). Wiring the real orchestrator client (feature-gated) is the follow-on,
22+
//! gated on Echidna's confirmed API.
23+
//!
24+
//! The value delivered now is the *seam*: one indirection point where a route
25+
//! is chosen, with the `Outcome` contract preserved, so adding the real client
26+
//! later touches only [`Dispatch::run`] — no backend or caller changes.
27+
28+
use crate::prover::{Backend, BackendKind, Outcome, Verdict};
29+
use anyhow::{bail, Result};
30+
use std::path::Path;
31+
32+
/// How a check is dispatched.
33+
pub enum Dispatch {
34+
/// Shell out to the tool locally (default; fully real).
35+
Local,
36+
/// Route to the Echidna orchestrator at `base_url` (honest stub for now).
37+
Echidna { base_url: String },
38+
}
39+
40+
impl Dispatch {
41+
/// The conventional Echidna orchestrator endpoint.
42+
pub const DEFAULT_ECHIDNA_URL: &'static str = "http://127.0.0.1:8090";
43+
44+
/// Parse a `--dispatch` value: `local` (default), `echidna`, or
45+
/// `echidna=<url>`.
46+
pub fn parse(s: &str) -> Result<Dispatch> {
47+
if let Some((head, url)) = s.split_once('=') {
48+
if head == "echidna" {
49+
return Ok(Dispatch::Echidna {
50+
base_url: url.to_string(),
51+
});
52+
}
53+
} else if s == "echidna" {
54+
return Ok(Dispatch::Echidna {
55+
base_url: Self::DEFAULT_ECHIDNA_URL.to_string(),
56+
});
57+
} else if s == "local" {
58+
return Ok(Dispatch::Local);
59+
}
60+
bail!("unknown --dispatch `{s}` (known: local, echidna, echidna=<url>)")
61+
}
62+
63+
/// Run `backend`'s check via the chosen route. The `Outcome` contract is
64+
/// identical regardless of route.
65+
pub fn run(&self, backend: &dyn Backend, file: &Path, include_root: &Path) -> Result<Outcome> {
66+
match self {
67+
Dispatch::Local => backend.check_file(file, include_root),
68+
Dispatch::Echidna { base_url } => Ok(echidna_stub(backend.kind(), base_url)),
69+
}
70+
}
71+
}
72+
73+
/// The honest Echidna stub: the seam is wired and returns the standard
74+
/// `Outcome` shape, but reports `Unavailable` with a reason instead of
75+
/// fabricating a verdict.
76+
fn echidna_stub(kind: BackendKind, base_url: &str) -> Outcome {
77+
Outcome {
78+
available: false,
79+
exit_code: None,
80+
ok: false,
81+
output_tail: format!(
82+
"echidna dispatch seam is wired ({base_url}) but the orchestrator \
83+
client is not built in this configuration; no verdict fabricated"
84+
),
85+
kind,
86+
verdict: Verdict::Unavailable,
87+
}
88+
}
89+
90+
#[cfg(test)]
91+
mod tests {
92+
use super::*;
93+
use crate::prover::{Agda, Smt};
94+
95+
#[test]
96+
fn parse_dispatch_values() {
97+
assert!(matches!(Dispatch::parse("local").unwrap(), Dispatch::Local));
98+
match Dispatch::parse("echidna").unwrap() {
99+
Dispatch::Echidna { base_url } => assert_eq!(base_url, Dispatch::DEFAULT_ECHIDNA_URL),
100+
_ => panic!("expected echidna"),
101+
}
102+
match Dispatch::parse("echidna=http://host:9000").unwrap() {
103+
Dispatch::Echidna { base_url } => assert_eq!(base_url, "http://host:9000"),
104+
_ => panic!("expected echidna with url"),
105+
}
106+
assert!(Dispatch::parse("nonsense").is_err());
107+
}
108+
109+
#[test]
110+
fn echidna_route_is_an_honest_stub_never_fabricates() {
111+
// The seam preserves the backend's kind but reports Unavailable with
112+
// a reason — it must never claim Proven/Refuted it did not obtain.
113+
let d = Dispatch::parse("echidna").unwrap();
114+
let tmp = tempfile::tempdir().unwrap();
115+
let f = tmp.path().join("q.smt2");
116+
std::fs::write(&f, "(check-sat)\n").unwrap();
117+
let out = d.run(&Smt::z3(), &f, tmp.path()).unwrap();
118+
assert_eq!(out.verdict, Verdict::Unavailable);
119+
assert!(!out.available);
120+
assert_eq!(out.kind, BackendKind::Solver, "kind is preserved");
121+
assert!(out.output_tail.contains("echidna"));
122+
assert!(out.output_tail.contains("no verdict fabricated"));
123+
}
124+
125+
#[test]
126+
fn local_route_delegates_to_the_backend() {
127+
// Local dispatch is exactly backend.check_file — same honesty either
128+
// way (present ⇒ real verdict; absent ⇒ Unavailable).
129+
let d = Dispatch::Local;
130+
let tmp = tempfile::tempdir().unwrap();
131+
let f = tmp.path().join("X.agda");
132+
std::fs::write(&f, "module X where\n").unwrap();
133+
let via_dispatch = d.run(&Agda, &f, tmp.path()).unwrap();
134+
let direct = Agda.check_file(&f, tmp.path()).unwrap();
135+
assert_eq!(via_dispatch.available, direct.available);
136+
assert_eq!(via_dispatch.verdict, direct.verdict);
137+
}
138+
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
pub mod config;
1414
pub mod dag;
1515
pub mod diagnostic;
16+
pub mod dispatch;
1617
pub mod event;
1718
pub mod graph;
1819
pub mod groove;
@@ -28,6 +29,7 @@ pub mod workspace;
2829

2930
pub use dag::{build as build_dag, DagDocument};
3031
pub use diagnostic::{Diagnostic, LintReport, Severity};
32+
pub use dispatch::Dispatch;
3133
pub use event::{Event, EventKind};
3234
pub use graph::{build as build_graph, ImportGraph};
3335
pub use groove::{manifest as groove_manifest, GrooveManifest};

src/main.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use anyhow::{Context, Result};
55
use arghda_core::lint::LintContext;
66
use arghda_core::{
77
build_dag, build_reason, event, groove_manifest, run_lints, unused, watcher, Agda, AgdaCubical,
8-
Backend, BackendKind, Idris2, Lean, LintRule, Probe, RuleConfig, Smt, State, Verdict,
8+
Backend, BackendKind, Dispatch, Idris2, Lean, LintRule, Probe, RuleConfig, Smt, State, Verdict,
99
Workspace,
1010
};
1111
use clap::{Parser, Subcommand};
@@ -90,6 +90,10 @@ enum Cmd {
9090
/// `z3`, `cvc5`.
9191
#[arg(long, default_value = "agda")]
9292
backend: String,
93+
/// Where the check runs: `local` (default) or `echidna[=<url>]`
94+
/// (route to the Echidna orchestrator).
95+
#[arg(long, default_value = "local")]
96+
dispatch: String,
9397
/// Emit the report as JSON.
9498
#[arg(long)]
9599
json: bool,
@@ -143,6 +147,10 @@ enum Cmd {
143147
/// default, in which case clean nodes are `unknown`.
144148
#[arg(long)]
145149
check: bool,
150+
/// With `--check`, where each check runs: `local` (default) or
151+
/// `echidna[=<url>]`.
152+
#[arg(long, default_value = "local")]
153+
dispatch: String,
146154
},
147155
/// Claim a file: inbox -> working.
148156
Claim { workspace: PathBuf, file: String },
@@ -210,8 +218,9 @@ fn main() -> Result<()> {
210218
file,
211219
include_root,
212220
backend,
221+
dispatch,
213222
json,
214-
} => check(&file, include_root.as_deref(), &backend, json)?,
223+
} => check(&file, include_root.as_deref(), &backend, &dispatch, json)?,
215224
Cmd::Dag {
216225
path,
217226
entry,
@@ -232,13 +241,15 @@ fn main() -> Result<()> {
232241
config,
233242
backend,
234243
check,
244+
dispatch,
235245
} => reason(
236246
&path,
237247
&entry,
238248
headline_pattern.as_deref(),
239249
config.as_deref(),
240250
&backend,
241251
check,
252+
&dispatch,
242253
)?,
243254
Cmd::Claim { workspace, file } => {
244255
transition(&workspace, &file, State::Inbox, State::Working)?
@@ -419,7 +430,13 @@ fn scan(
419430
Ok(())
420431
}
421432

422-
fn check(file: &Path, include_root: Option<&Path>, backend_name: &str, json: bool) -> Result<()> {
433+
fn check(
434+
file: &Path,
435+
include_root: Option<&Path>,
436+
backend_name: &str,
437+
dispatch: &str,
438+
json: bool,
439+
) -> Result<()> {
423440
if !file.is_file() {
424441
anyhow::bail!("file not found: {}", file.display());
425442
}
@@ -443,7 +460,7 @@ fn check(file: &Path, include_root: Option<&Path>, backend_name: &str, json: boo
443460
};
444461
let report =
445462
run_lints(file, &ctx, &rules).with_context(|| format!("linting {}", file.display()))?;
446-
let outcome = backend.check_file(file, include_root)?;
463+
let outcome = Dispatch::parse(dispatch)?.run(backend.as_ref(), file, include_root)?;
447464

448465
// Honest verdict: a lint hard-block rejects; otherwise report the
449466
// backend's ACTUAL verdict word. Only `Proven` (with no hard block) is
@@ -532,6 +549,7 @@ fn reason(
532549
config: Option<&Path>,
533550
backend_name: &str,
534551
do_check: bool,
552+
dispatch: &str,
535553
) -> Result<()> {
536554
let backend = backend_for(backend_name)?;
537555
let (roots, rules, cfg) = resolve_roots_and_rules(
@@ -556,9 +574,10 @@ fn reason(
556574
let mut verdicts = std::collections::BTreeMap::new();
557575
let stale = std::collections::BTreeSet::new();
558576
if do_check {
577+
let route = Dispatch::parse(dispatch)?;
559578
for node in &dag_doc.nodes {
560579
let file = include_root.join(&node.file);
561-
let outcome = backend.check_file(&file, include_root)?;
580+
let outcome = route.run(backend.as_ref(), &file, include_root)?;
562581
if outcome.available {
563582
verdicts.insert(node.id.clone(), outcome.verdict);
564583
}

0 commit comments

Comments
 (0)