Skip to content

PR 2/3: Rust application framework (PyO3) - #97

Open
ericeil wants to merge 32 commits into
masterfrom
eric/rust
Open

PR 2/3: Rust application framework (PyO3)#97
ericeil wants to merge 32 commits into
masterfrom
eric/rust

Conversation

@ericeil

@ericeil ericeil commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PR 2 of 3 — Rust application framework (PyO3)

Part of the stacked split of eric/crucible.
Stack: mastereric/ecosystem (#96, merged) → eric/rusteric/crucible-app.
Base: master. 79 files, +10.7k/-278.

Everything here serves one goal: **let an AutoProver application be implemented in Rust, as requested by the Solana Foundation. This PR is the host that makes that possible. It carries no verification backend; the
first one (Crucible) is PR 3.

What this adds

The generic wheel host — composer/rustapp/
A wheel supplies a declarative AppDescriptor and answers pure callouts; the host owns all control
flow, all effects, and the entire vertical an application needs — argparse, service setup, pipeline
wiring, frontend, artifact store, main().
Why: a Rust application should only have to describe itself and answer questions. Everything a
wheel would otherwise reimplement in Python lives here once.

A typed ABI on both sides — descriptor.py (load time) and wire.py (runtime)
The descriptor is pydantic-validated at load; the runtime unions are tagged (CompileOk | CompileFailed, ValidateBuildFailed | ValidateVerdicts) and the ten callouts are a Protocol checked
at import.
Why: a field renamed on the Rust side fails at the boundary, instead of reading as "" three
frames later.

The Rust workspace — rust/
autoprover-sdk — the ABI serde types, the Application / FormalizeSession traits, and the
export_app! macro that emits the PyO3 module. example-app (echoprover) — a demo wheel with zero
bespoke Python. run-confined gains a maturin bin pyproject.
Why: the Rust half of the ABI needs a home, and the framework needs something real to round-trip
against in CI.

Backend preflight — composer/pipeline/core.py
New PipelineBackend.preflight: whatever a backend can do before it knows anything about the
program, run concurrently with system analysis, awaited first, its failure cancelling the analysis.
Why: a backend that must build something can gate a broken workspace before the model is spent,
instead of surfacing it as unfixable compiler errors in the first authored draft.

StagedFormalizer replaces the Formalizer.begin hook
prepare_formalization widens to a union, and the shared setup artifact becomes a constructor
argument to the only object that uses it.
Why: no post-hoc writes into a live formalizer — the same rule as the rest of the phase chain.

PipelineBackend becomes a nominal ABC, not a structural Protocol
Each backend names its eight type arguments in its class line.
Why: conformance is checked where the backend is defined; both docstring copies of those arguments
had already drifted.

Descriptor-driven RAG — rag/import_format.py, scripts/rag_import.py, tools/rag_env.py
A producer emits a common JSON manifest, one shared importer ingests any manifest, and a wheel names
its corpus by tag. Ships corpus-free — both registries are empty here.
Why: a knowledge base should be data, not another bespoke Python builder per application.

Report / IO / UI seams
ReportBackend gains "none" with its own labels, for a pipeline that records properties without
verifying them (so the null Solana backend stops borrowing a real verifier's wording);
RuleVerdict.message carries a backend's diagnostic into report.json and the HTML (so a
counterexample is triageable from the report alone
); io.context.push_custom_update (so a backend
can emit domain events between graph calls
); MultiJobApp.mark_pipeline_done() (replacing seven
external writes to a private field
); one outcome_label / outcome_glyph table (the console
rollup and the TUI had drifting copies
).

Build and CI
A bare uv sync now builds the Rust artifacts (dev includes the apps group, cache-keys over
each crate's .rs sources); rust-toolchain.toml at the repo root; rust/Cargo.lock tracked.
pytest's CI job installs the toolchain and builds the crates; pyright's job gets --no-dev.
Why: no manual maturin develop step for contributors, and test_rustapp stops silently skipping
in CI.

Docsapplication-abstraction.md, formalization-abstraction.md, rust-applications.md,
rag-import-format.md, rust/README.md.

Deliberately deferred to PR 3

The framework layer holds no Cargo shape, no chain-specific build, and no verifier's vocabulary. So
Crucible's build path (spec/solana/build.py, spec/cargo.py), its report vocabulary, and its RAG
corpus registration all land with the backend that needs them — reached here through the two empty
registry seams in rustapp/toolchain.py, and rust/Cargo.toml omits crucible-app.

Verification

  • pytest -m "not expensive"583 passed, 11 deselected.
  • pyright0 errors.
  • cargo build --manifest-path rust/Cargo.toml — clean.
  • ~90 new tests across test_rustapp*, test_rust_frontend, test_rust_llm_agent,
    test_pipeline_overlap, test_rag_import, test_rag_env, test_solana_component_grouping.
  • test_solana_gate ships here but its test_scenarios/solana_vault fixture lands in PR 3, so it is
    only runnable from the tip of the stack. It is expensive-marked (real LLM + containers) and its
    real-LLM run has not been executed.

🤖 Generated with Claude Code

@ericeil
ericeil force-pushed the eric/rust branch 3 times, most recently from 4bbeb08 to 7ea1a77 Compare July 23, 2026 19:58
@ericeil
ericeil force-pushed the eric/ecosystem branch 2 times, most recently from 833557a to fe84bae Compare July 23, 2026 20:15
@ericeil
ericeil force-pushed the eric/rust branch 6 times, most recently from 0671f74 to 5361ead Compare July 24, 2026 00:08
@ericeil
ericeil force-pushed the eric/rust branch 2 times, most recently from ee74e24 to 372504a Compare August 3, 2026 21:30
ericeil and others added 15 commits August 4, 2026 11:12
The generic Rust-wheel host (composer/rustapp) + the rust workspace (autoprover-sdk
ABI/export_app! macro, example-app/echoprover), consuming the command-sandbox seam
already upstream (via the `none` passthrough; SandboxConfig.backend_spec ->
{argv_prefix, timeout_s}). Includes composer/spec/solana/build.py (workspace prep),
the rust prompt templates, and the report-layer support the host needs
(report/{schema,render,collect}.py: the ReportBackend set incl. "crucible", the
per-backend outcome_label vocabulary, and Verdict.message diagnostics).

Cross-cutting intermediate forms (finalized in PR3):
- rust/Cargo.toml: workspace members omit crucible-app (added in PR3).
- pyproject.toml / uv.lock: the `apps` group + [tool.uv.sources] omit crucible_app
  (its crate lives in rust/crucible-app, which lands in PR3), so `uv sync`/`uv run`
  resolve here; PR3 re-adds it.
- rustapp/adapter.py: RustFormalizer casts the backend tag directly; PR3 restores the
  validating as_report_backend.
- rust/.gitignore ignores rust/Cargo.lock; the lockfile is untracked here.

Gate: test_rustapp (echoprover decider round-trip; sandbox passthrough) -- 15 passed.
CI pyright (composer/ analyzer sanity_analyzer certora_autosetup) -- 0 errors.
test_solana_gate lives here (imports composer.rustapp.frontend), not PR1.

Stacked-PR 2 of 3 (off eric/ecosystem); see docs/pr-split-plan.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The identity types split upstream: SourceIdentifier is now the neutral type
the ecosystem seam speaks, with SolidityIdentifier and RustIdentifier as its
per-language subtypes. These two sites predate the split and still claimed
Solidity.

Both typecheck either way, because narrow->wide assignment is legal — a
SolidityIdentifier IS a SourceIdentifier. So the checker cannot catch these;
they have to be retargeted by hand.

- rustapp/entry.py: the generic Rust host parses --main-contract into
  SourceFields.contract_name, so it builds the neutral SourceIdentifier. It
  is descriptor-driven and not Solana-specific, so it should not claim a
  language at all.
- tests/test_solana_gate.py: this one does know its target is a Rust
  program, so it builds a RustIdentifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Editing Rust required remembering a manual step (`maturin develop` for the app
wheel, `cargo build -p run-confined --release` for the launcher, plus a one-time
`maturin_import_hook site install`). Make the venv the single source of truth
instead:

* `dev` includes the `apps` group, so a bare `uv sync` builds the Rust
  artifacts. The container's UV_NO_DEV=1 still selects none of them, so its
  cargo-less final stage is unaffected.
* `[tool.uv] cache-keys` over each project's `.rs` sources (including
  cross-crate, so an autoprover-sdk edit invalidates echoprover) — uv rebuilds
  on the next `uv run`, and the import hook becomes optional.
* run-confined ships as a maturin `bin` wheel, landing the binary in
  `.venv/bin`; `_resolve_binary` also probes the interpreter's scripts dir,
  since PATH misses it when the venv is not activated. Linux-only, hence the
  `sys_platform` marker.
* rust-toolchain.toml pins the toolchain and lets rustup install it on demand.
  It sits at the repo root because rustup resolves by CWD and ignores
  `--manifest-path`, and cargo runs both from crate dirs and from the root.
* Track rust/Cargo.lock: this workspace ships artifacts, so the dependency
  versions are part of the build.

pyright's job gets `--no-dev` — it would otherwise compile Rust it cannot see
into. pytest's job now does build the crates, so tests/test_rustapp.py stops
silently skipping in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Crucible work (PR3, `eric/crucible-app`) kept improving the layer beneath
it, so the two branches had drifted: the framework files on `eric/rust` were
stale copies of the same files on `eric/crucible-app`. This lifts the
framework-layer half of that drift down to where it belongs, leaving
`eric/crucible-app` to carry only crucible-specific files.

What comes down, by area:

* **Pipeline driver** — `PipelineBackend.preflight`, run concurrently with
  system analysis and joined by `_all_or_none` so either side failing cancels
  the other. This is what lets a backend that must *build* something gate the
  workspace before the model is spent, instead of surfacing a broken workspace
  as unfixable compiler errors in the first authored draft. `prepare_system`
  takes the preflight result as its third argument; a setup failure now
  surfaces where it happens rather than after extraction.
* **Rust application framework** (`composer/rustapp`, `rust/autoprover-sdk`) —
  the abstract component unit mirroring EVM's, the declarative `preflight` /
  `idl_dest` / setup-artifact slots on the descriptor, the cached shared setup
  artifact, the bounded in-loop review, and IDL-driven type generation for a
  wheel that cannot link the program under test.
* **Cargo/Solana capabilities** — `composer/spec/cargo.py` (resolve a program
  crate from its source path, not its name) and `composer/spec/solana/build.py`
  (fill in an IDL's program id when the project's build omits it; warm the
  cargo cache with the same cargo the sbf build uses).
* **Sandbox recipes** — a private per-run `RUSTUP_HOME`, the `PATH`
  `cargo-build-sbf` install tree, `~/.gitconfig`, a pinned registry protocol,
  and `CARGO_NET_OFFLINE=true` (the spelling every cargo accepts).
* **RAG seam** — `composer/tools/rag_env.py`, which `rustapp/entry.py` already
  imports. The corpus modules stay in PR3; an absent one degrades to no RAG,
  which is this module's documented contract.
* Docs for the above, plus the report template rendering `Verdict.message`.

Also fixes the demo wheel: `rust/example-app`'s descriptor gains
`preflight: None`. It has not compiled since `preflight` was added to
`AppDescriptor` on the crucible branch — that branch's `uv sync` never built the
crates, so nothing noticed. Here it would break the `test_rustapp` gate the
moment the wheel is rebuilt, so it is fixed in the same commit that brings the
SDK change down.

Verified: `cargo check --workspace` and `uv sync` clean, pyright 0 errors, and
the framework/pipeline/sandbox/solana tests plus the `test_rustapp` gate pass —
422 passed vs 363 on the branch before, with no new failures. `test_solana_gate`
fails here for a pre-existing reason: its `test_scenarios/solana_vault` fixture
lives in PR3.
Ported from eric/ecosystem, plus the Rust-side half that branch has no backend for.

`Formalizer.begin` was a defaulted no-op hook that every formalizer inherited and
that the driver called unconditionally. It carried its ordering as a call-order
convention and mutated the formalizer in place, contradicting `Formalizer`'s own
contract ("immutable, fully constructed by prepare_formalization ... never set
post-hoc") — the one thing the rest of the phase chain is built to avoid.

Replace it with `StagedFormalizer`, whose abstract `begin` *returns* the
`Formalizer`. `prepare_formalization` widens to the union of the two, and the
driver picks the arm.

On the Rust side that removes the last post-hoc write to a live formalizer:
`RustFormalizer` no longer takes a `setup_author` and no longer assigns
`_setup_result` / `_context_extra[context_key]` after construction. A wheel that
declares a `setup` step now gets `RustStagedFormalizer`, which authors the shared
artifact and calls the `build` closure `prepare_formalization` handed it; a wheel
that declares none gets its formalizer straight from `build(None)`. Either way the
artifact is in the context blob before any component can read it.

Backends with no shared artifact (prover, foundry, null-Solana) are unchanged:
their narrower `-> Formalizer[...]` return now states that positively instead of
inheriting a no-op.

Also brings over the CLAUDE.md testing notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight fixes from the review of this branch, plus tests for the two that were
behavioural. Nothing here changes the design — see REVIEW-eric-rust.md for the
typing/abstraction work that is still open.

* results: the console/TUI rollup read `next(iter(verdicts.values()))` on the
  strength of "one verdict per delivered unit", but `units()` is one unit per
  *property* — so a component with five properties reported one check and hid
  the other four. One row per verdict now, named by the property title it
  checks (new `RustFormalResult.unit_titles()`), falling back to the unit name.
  A delivered component that bakes no verdict still gets an UNKNOWN row.
  `report.json` was never affected; it goes through `fetch_verdicts`.

* pipeline: `_all_or_none` left its tasks running when the *caller* was
  cancelled — `asyncio.wait` does not touch what it waits on, so a Ctrl-C left
  a multi-minute cargo build detached, still writing into the workdir. It now
  cancels them and re-raises. A task cancelled by a third party counts as a
  failure (and `exception()` is no longer asked of a cancelled task, which
  raises).

* sandbox: the per-run RUSTUP_HOME's `toolchains` symlink was tested with
  `exists()`, which follows the link — a stale link (shared rustup home moved)
  read as absent and `symlink_to` would then raise FileExistsError. Check the
  link itself and re-point it.

* pyproject: drop `console-crucible` / `tui-crucible`. They named
  `composer.crucible_launch`, which lands in PR3 — an entry point pointing at a
  missing module installs happily and fails at ImportError on first use. Same
  for null_backend's `:mod:`composer.crucible`` reference.

* rag_env: the tag -> connection map existed twice (here and
  `rag/db.KNOWLEDGE_BASES`); take it from `KNOWLEDGE_BASES` and keep only the
  tools factory local. Split the two failure modes that were both being
  swallowed: an unregistered tag is a wheel bug, so `validate_rag_db` runs at
  descriptor load like `resolve_ecosystem` does, while an unavailable DB /
  embedding model still degrades to no RAG.

* descriptor: `backend_tag: ReportBackend`. It feeds a closed set, so a wheel
  declaring a tag the report cannot render now fails in `model_validate_json`
  — before the run spends anything — rather than at formalizer construction.
  This caught the demo wheel declaring `backend_tag: "echoprover"`, which no
  report knows: any real echoprover run died in `RustFormalizer.__init__`. It
  borrows `"prover"` now, as the null Solana backend does.

* adapter: `formalize` grouped its report rows as `(property, [one unit])`
  singletons, so two units checking one property became two rows with the same
  key and the store's `dict()` kept the last. Group by property as they arrive.

* mark the `docs/crucible-*.md` citations that land in PR3, so a reader stops
  looking for files this branch doesn't carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wheel's *declarative* ABI was already mirrored as pydantic models
(`descriptor.py`); its *runtime* ABI was not. Python received every payload as a
bare dict and destructured it by string key — `result.get("status") != "ok"`,
`res.get("kind") == "build_failed"` then `res["verdicts"]`, `u.get("target") or
u["unit"]`, `plan.get("files")` — while the Rust side had spelled the same things
as tagged unions all along. A field renamed in autoprover-sdk read as `""` three
call frames later instead of failing at the boundary.

New `composer/rustapp/wire.py`, peer of `descriptor.py`:

* Inbound, tagged: `CompileOk | CompileFailed` (discriminator `status`) and
  `ValidateBuildFailed | ValidateVerdicts` (discriminator `kind`), so
  `isinstance` replaces the string compare and neither variant can be asked for
  the other's fields. Plus `Unit` (whose `target_or_unit()` is no longer
  reimplemented inline), `Verdict`, `WorkspacePrep`, `SandboxGrants`, `Prompt`.
* Outbound: `AuthorInput` (+ `Property`, `ProgramCrate`), `Failure`/`FailureKind`
  (so `{"kind": "judge"}` is a value, not a literal), and `FinalizeInput` —
  currently the only written definition of that payload, since the Rust
  `finalize` still takes an opaque `serde_json::Value`. Growing an `Outcomes`
  struct over there is the follow-up.
* `RustAppModule` Protocol replaces `module: Any` in every signature. Members
  are `Callable` fields so `CALLOUTS` derives from the annotations rather than a
  hand-kept copy; `load_module` checks all ten at import and names the gaps, so a
  wheel built against an older SDK fails at load instead of with an
  AttributeError mid-run. The one cast sits at `import_module`, which is where
  the dynamism actually is.

`component` and `context` stay dicts on purpose: they are opaque JSON the host
only forwards, so typing them would mean inventing a schema for values it never
reads.

Rust side: `Verdict.outcome` becomes an `Outcome` enum (UPPERCASE serde rename,
so the wire bytes don't change), with `Verdict::detailed()` for the failing case
a backend almost always wants. A typo no longer compiles. Python still tolerates
an unknown label (-> UNKNOWN, logged): version skew should cost one row's wording,
not the component's results.

Fallout worth noting:

* `RustFormalResult.verdicts` is `dict[str, Verdict]`; `fetch_verdicts` and the
  console rollup read fields, and `results._parse_outcome` is gone.
* `env: Any` -> `ServiceHost` in the authoring turn, which retired
  `getattr(env, "all_tools", None) or env.rag_tools`.
* `_split_prompt` is gone. A wheel that sends no `instruction` now fails at the
  seam; it used to have its whole payload JSON-dumped into the agent's prompt.
* `from_formalized` deleted — it parsed a Rust `Formalized`/`Command::Publish`
  that no longer exists in the SDK, and only tests called it. `as_report_backend`
  deleted too: pydantic validates `backend_tag` now.
* The stub *wheels* in tests still return JSON strings, as real ones do. Only the
  host's side of the seam moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reach-throughs, one shape: a caller needed something an object owned, so it
took it out of a private field instead of the object growing a way to ask.

The phase enum. `RustBackend._phase: type` / `_core_phases` were dataclass
*fields*, so `host.build_backend` constructed the backend with underscore-named
keywords, and both callers that needed a phase member indexed the field through
`cast(Any, …)` — `RustPreparedSystem` reaching across objects to do it
(`cast(Any, b._phase)[setup.phase_key]`). They are now public `phase:
type[enum.Enum]` / `core_phases: CorePhases` (the property is redundant — a plain
attribute satisfies the protocol, as ProverBackend and the null Solana backend
already show), and the indexing lives behind one accessor:

    def task_info(self, spec: StepSpec) -> TaskInfo[enum.Enum]

Annotating the field `type[enum.Enum]` is what let the casts go: pyright resolves
EnumMeta's `__getitem__`, so the member comes back typed. Both call sites were
building a TaskInfo from a (phase_key, label) pair anyway, so that is what the
method returns — and since PreflightSpec and SetupSpec now share a `StepSpec`
base carrying `step: ClassVar[str]` (the step's kind), the task id is derived
from the declaration rather than spelled `f"{name}-setup"` at the call site.
ClassVar keeps `step` off the wire, so the Rust structs don't change.

The TUI flag. `MultiJobApp.mark_pipeline_done()` replaces five external
`app._pipeline_done = True` writes across four entry points plus two inside
`ui/pipeline_app.py`; the flag is now touched only by the class that declares it.
The method's docstring records why it exists at all — quitting is refused until
the run ends, so a keypress can't close the app and take every panel with it
mid-stream — which none of the assignments said.

Both new tests assert the property the reach-through existed to provide: that a
step's task carries the member of *the backend's own* enum, since the frontend
looks up section labels by member identity and a member from another copy of the
enum would land the task in no section at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`(bool, str)` carried three meanings. The string was a revise instruction when
the bool was False and an aside when it was True, and `(True, "")` *also* stood
for "this wheel declares no judge" — so every caller had to know which of the
three it was holding, and `_budgeted`'s relent step returned `(True, rejection
text)`, a verdict that read as an acceptance while carrying the opposite.

    Accepted(feedback="")     # the gate opens; feedback is an aside
    Rejected(feedback=...)    # the gate holds; feedback is what to revise
    None                      # no judge for this input — no verdict at all

`_judge_turn` returns `Review | None`, so absence is absence: `author_and_compile`
now re-authors on `isinstance(review, Rejected)`, and both "accepted" and "no
judge" simply fall through. `_budgeted`'s last round produces a real `Accepted`
whose feedback is the unresolved objection — the same behaviour as before, but the
type now says what it does. `_make_judge_hook` narrows `None` away where it cannot
happen (the hook exists only for an input that declared a judge) and says why.

Two `_parse_judge` behaviours were previously implicit in the tuple:

* A rejection with no feedback used to hand the next authoring turn an empty
  revise context — a round spent on "you were rejected" with no statement of
  what to fix. It now says that no reason was given.
* Prose that leads with neither ACCEPT nor REJECT is taken as an acceptance.
  Unchanged, but now stated in the docstring and pinned by a test: the reviewer is
  advisory, in front of the compile/validate gates that actually decide, so an
  unparseable reply lets the draft through rather than burning a revise round on a
  verdict nobody stated. Flipping that is a policy decision — flagged, not taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of these had a value that looked like data but meant "there isn't one", so
every consumer had to know the convention — and one of them silently didn't.

* `cargo._dep_req` returned `""` for "no anchor-lang requirement to compare",
  which is what a caller comparing versions least wants to receive. It is
  `str | None` now, and `ProgramCrate.anchor` with it; the `""` the Rust struct's
  `#[serde(default)]` fields require is produced at the wire boundary
  (`wire_crate`) and nowhere else.

* `run_llm_agent` JSON-dumped a missing result, so a turn where the agent never
  called the result tool handed back the literal string "null" — which went on to
  `compile` as if it were the authored artifact and spent an attempt on a build
  nobody could have fixed. It returns `str | None`; both loops treat "no artifact"
  as its own failure, costing an attempt but never reaching the toolchain, and the
  next prompt is told what actually happened. A judge turn that ends without a
  verdict is likewise not a rejection: it fails open, same reasoning as an
  unparseable reply.

* `resolve_program_id` / `idl_with_program_id` took the crate as a dict and read
  it with `crate.get("dir", ".")` — a Python-to-Python call flattening a typed
  value and then papering over its absence by scanning the root as if it were the
  crate, matching against a set of empty names. They take `ProgramCrate | None`,
  and the fallback is stated once, where it happens. `run_workspace_prep` gets the
  resolved crate threaded in rather than reconstructing it from the wire copy,
  whose emptiness no longer says whether anything was resolved.

* `RustFormalizer._idl` collapsed "prep placed no IDL" into `""` on the way in;
  it keeps `None` and flattens at `finalize`, which is the only place the payload
  promises a string.

The fourth bullet of the review's §4 (`context_key if descriptor.setup else
"setup"`, an unreachable fallback inventing a context key) went away with the
typed-ABI commit, which made `build` take the key alongside the artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two places where a string was doing a type's job.

The design-doc discovery task's phase was found by looking for a declared phase
whose *key* was literally "discover_design_doc". The descriptor already has a
mechanism for "this declared phase fills that role" — `core_slot` — so a magic
key was a second, undocumented one: a convention a wheel author had to spell
exactly right, with no error if they didn't, and `-> Any` at the end of it.

`CoreSlot` gains `DISCOVERY`, and `CoreSlot.required()` names the four the driver
itself tags and every application must map — so the new slot is optional, and
unclaimed still falls back to the first declared phase. Mirrored in the Rust
`CoreSlot` (additive: existing wheels don't mention it).

The other: two glyph tables with identical contents, one keyed by `Outcome`
(the console rollup) and one by raw strings (the TUI), because the emit payload
carried `"GOOD"`/`"BAD"` as literals. They are now `render.outcome_glyph`, beside
`outcome_label` — the same question, how an outcome reads to a human — and the
tolerant `str -> Outcome | None` is `Outcome.parse`, used by the frontend and by
`wire.Verdict`'s validator instead of each keeping its own known-values set. An
outcome this host doesn't recognize loses its glyph, not its line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 7 of the review: the duplication and dead code.

- build_arg_parser is the only parser. rust_entry_point re-declared the same
  nine arguments inline, and the copy had already lost every help string. The
  declared-flag dests no longer ride out of _add_declared_args as a return
  value either: _arg_dest/_declared_args derive them, so "declared" and
  "collected" cannot drift apart.
- build_default_env replaces build_neutral_env plus _default_env_builder's
  inner closure, which were the same six lines twice differing in rag_tools=.
  rust_entry_point binds rag_db=descriptor.rag_db_default with partial, which
  also keeps the corpus lookup lazy. Renamed because "neutral" described only
  one of the two behaviours; docs/rust-pure-app.md §5.1 records the landed name
  for the proposal it implements.
- Deleted _before_formalize: a no-op hook with no overrider, on a branch whose
  thesis is that applications ship no Python. What it documented is now a
  comment where it matters, and the "hooks an application backend may override"
  banner over _context went with it.
- _run_blocking has one body, guarding on nullcontext() when there is no
  semaphore.
- Hoisted the function-local imports that had no reason to be local (io.context,
  diagnostics.timing, sandbox.recipes); the spec.solana.build one stays and now
  says why the generic host doesn't name a chain at import time.
- RUST_FORBIDDEN_READ is one literal instead of being rebound four lines after
  it is defined.
- RustLanguage.source_crate is a method: as a Callable field it advertised an
  injection point that source_crate_of's isinstance dispatch makes meaningless.
- AppDescriptor.unit_noun(plural=) owns the component_noun fallback and the
  pluralization that cli.py spelled twice; cli.py's helpers take
  app: RustApplication.
- store.py: dict comprehension -> dict().

Tests: new test_rustapp_toolchain_sem.py covers _run_blocking (serialize_toolchain
had no coverage at all, so a rewrite of that guard could have gone unnoticed) —
including that four concurrent callouts never overlap and a raising one releases
the permit. test_rustapp.py pins the help text the duplicate parser had lost, the
declared-arg threading, and unit_noun.

pyright 0 errors; 463 passed, 11 deselected with the demo wheel importable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
crucible_kb was half registered: composer.rag.db.KNOWLEDGE_BASES carried its
connection and rag_env._FACTORIES carried a factory, but the module that factory
imports (composer.tools.crucible_rag) lands in PR3. The tag therefore passed
validate_rag_db — both halves present — and then build_rag_tools caught the
ModuleNotFoundError in its degrade-on-anything path and logged "RAG unavailable",
reporting a repo gap as an environment condition. That is the confusion rag_env's
two failure modes exist to keep apart, and it was reachable: a wheel declaring
crucible_kb would have run with no RAG and a single warning line.

Both registries are empty now, so such a wheel fails at descriptor load with "not
a registered RAG corpus" instead, and PR3 adds the tools module, the _FACTORIES
entry and the KNOWLEDGE_BASES entry in one go. CRUCIBLE_DEFAULT_CONNECTION goes
with them (nothing else read it), as does the comment naming
composer.scripts.rag_import, which does not exist on this branch either. The
error message now says "none is registered yet" rather than "known: []".

The pyproject.toml crucible comments stay: nothing there points at a missing
module (the entry points that did were deleted earlier), so they only explain why
those lists look short.

Tests: new tests/test_rag_env.py — the registry had no coverage at all. Includes
half-registrations (either half) still refusing, which is the shape that slipped
through, and a stub registration that doubles as the spec for what PR3 adds.

pyright 0 errors; 470 passed, 11 deselected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_all_or_none` gave both of the driver's overlaps one fate, which is more
than either needs.

The preflight is cheap by construction, so there is nothing to save by
cancelling it: await it first, and let its failure cancel the analysis
agent racing it — the direction where the spend actually is. An analysis
failure now waits the preflight out and reports itself.

The second pair (`prepare_formalization` ∥ extraction) goes back to
awaiting each in turn, no cancellation either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.sandbox_cargo` / `.sandbox_rustup` / `.sandbox_tmp` were spelled out in the
recipes that create them, in the forbidden-read regex that has to hide them from
the source tools' file listing, and in a test's assertions. Hoist them to
SANDBOX_{CARGO,RUSTUP,TMP}_DIR next to the functions that create them, and build
the regex branches from those via re.escape (the joined pattern is byte-identical
to the old literal).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ericeil and others added 2 commits August 4, 2026 11:16
This branch is the Rust *backend* framework (PR2) on the ecosystem seam (PR1). It
had also accumulated the code for building the program **under analysis** — which
belongs to the backend that does that, not to the framework. A Rust backend need
not build a crate to validate a program, nor depend on the analyzed crate, nor use
the sandbox at all, so none of it can sit in the layer every Rust backend shares.
It moves forward to `eric/crucible-app` (PR3).

What goes, by area:

* **The Solana build capability** — `composer/spec/solana/build.py` in full
  (`build_program`, `warm_cargo_cache`, the `Anchor.toml`/`declare_id!` program-id
  resolution, the IDL address fill-in), and the warm/build/place-IDL half of
  `run_workspace_prep` that drove it.
* **Cargo crate resolution** — `composer/spec/cargo.py`, plus the `RustLanguage`
  facet and `source_crate_of` dispatch it was reached through. `ecosystem.py` is
  back to what PR1 wrote: `RUST = Language(...)`.
* **Sandbox recipes** — the private per-run `RUSTUP_HOME`, the `PATH`
  `cargo-build-sbf` install tree, `~/.gitconfig`, the pinned registry protocol,
  `CARGO_NET_OFFLINE=true`, and the `SANDBOX_*` scratch-dir constants.
  `composer/sandbox/recipes.py` is byte-identical to master again.
* **The confined-build exclusions** in `RUST_FORBIDDEN_READ` (`.sandbox_cargo` /
  `.sandbox_rustup` / `.sandbox_tmp` / nested `target/`). PR1 had already deferred
  these to "the layer that introduces confined Rust builds" and PR2 took delivery;
  the premise was wrong, and the NOTE now names the *backend* instead.
* **Crucible's report vocabulary** — the `"crucible"` outcome/group labels and
  `ReportTerms`, and its member of the closed `ReportBackend` set.

Two seams replace the imports, both in `composer/rustapp/toolchain.py`, both empty
here and registered per chain by the application that needs them:

* `WORKSPACE_TOOLCHAINS` — executes the toolchain half of a `workspace_prep` plan.
  The host still writes the plan's `files` (ecosystem-neutral) and reports the IDL
  path back as the `idl` context key; it just no longer knows what a build is. It
  takes the analyzed `SourceFields` rather than a resolved crate, so the framework
  holds no Cargo shape. **Unregistered raises** — a plan that only places files
  never asks, so reaching it means the wheel asked for a build nothing can perform,
  and skipping it would resurface as a compile error the authoring agent can't fix.
* `SOURCE_CRATES` — resolves `AuthorInput.program_crate`. **Unregistered degrades**
  to an all-empty `ProgramCrate`: that is already what Solidity and an unreadable
  layout yield, and the SDK's `ProgramCrate::resolved` fills it from the wheel's own
  convention, so "no resolver" and "nothing to resolve" are honestly the same answer.

The wheel ABI is untouched (`WorkspacePrep`, `ProgramCrate`, `AuthorInput`, the
`Sandbox` argv prefix), so PR3 re-adds no interface — only implementations.

The null Solana backend was reporting under `backend_tag="crucible"`, borrowing a
real verifier's wording for an all-UNKNOWN report. The closed `ReportBackend` set
gains **`"none"`** for it — a pipeline that records properties without verifying
them — whose `UNKNOWN` reads "Unverified" rather than "Unknown". Three framework
test fixtures that named their fake wheel `"crucible"` are now `"demoprover"`, and
the verdict-rollup / report tests assert a generic backend's words.

Generic mechanism that pointedly stays: `Verdict.message` and its rendering,
`Outcome.parse`, `outcome_label`/`outcome_glyph`, and the `argv_prefix` confinement
seam a wheel may or may not use.

Verified: `pytest -m "not expensive"` 439 passed / 11 deselected, `pyright` 0
errors. The forward half is a patch verified both ways against this tree: applied,
476 passed / 0 errors; reverse-applied, back to exactly this state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ecosystem PR (#96) landed on master, so this branch rebases onto master
directly instead of onto `eric/ecosystem`. Master moved three APIs underneath
the Rust framework in the meantime; this is the reconciliation.

* **The source-tool read filter is a predicate** (#120, plus the graphcore
  `vfs-forbidden-predicates` bump the rebase brings in). `FS_FORBIDDEN_READ`
  is gone; `build_default_env` takes `GlobalExcludeArg` — the `str |
  Callable[[PurePath], bool]` union `Language.default_forbidden_read` already
  declares — and defaults to `fs_forbidden_read`. `RUST_FORBIDDEN_READ` stays
  a pattern: nothing in the Cargo layout needs carving back out of an excluded
  directory, which is the case the predicate exists for.
* **`TieredProviders.provider_kind` is now `provider_service`.** Same rename
  `composer/pipeline/cli.py` carries for the built-in entry points.
* **`llm_factory` is gone from `composer.workflow.services`** — an unused
  import here and in `test_solana_gate.py`, so both just drop it.
* `InMemoryTextFile` takes a `ContentRenderer`, not a `provider` string.

Verified: pyright 0 errors, 573 passed / 11 deselected. (`hypothesis` was
missing from the local venv, not from `pyproject.toml`'s test group — a local
gap, present on master too, not something this branch introduces.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ericeil
ericeil changed the base branch from eric/ecosystem to master August 4, 2026 18:35
ericeil and others added 15 commits August 4, 2026 16:12
`PipelineBackend` was a structural `Protocol`, so a backend's eight type
arguments were never written anywhere a checker could see them — each one
restated them in a docstring instead, and both had drifted: the null Solana
backend listed 7 of the 8 (no `Pre`), and `ProverBackend` claimed
`A = ComponentSpec` when its store is keyed by `ComponentSpec | InvariantSpec`.
Conformance was only checked where a backend reached `run_pipeline`, and a
renamed member would have silently stopped matching there rather than at the
definition.

It is now an `ABC` with the three methods abstract, and each backend names its
type arguments in its `class` line.

The four non-method members stay read-only properties — the shape the Protocol
already declared — because that is what nominal inheritance allows: a mutable
attribute override is invariant, which would reject `ProverBackend` narrowing
its store to `ProverArtifactStore` (its prepared system needs
`write_component_runs`) and `RustBackend` deriving its guidance from the wheel's
descriptor. Declaring them as attributes instead rejects the derived properties;
declaring them as properties breaks any same-named dataclass field at runtime,
since the generated `__init__` assigns through a setter-less property. So each
backend holds its store in a field and returns it from the accessor.

The driver's signature is unchanged, so `run_pipeline`, `cli.Continuation`, and
both entry points needed no edits; the partial backend stubs in the pipeline
tests still duck-type, as the driver never does an `isinstance`.

One trade: pyright cannot flag a subclass that omits an abstract property (an
inherited declaration counts as declared), so that mistake now surfaces as a
`TypeError` at construction rather than at the call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The manifest format and its importer arrived with the Crucible application, but
nothing in either is Crucible's: `import_format.py` is a pydantic schema with no
RAG-stack imports at all, and `rag_import.py` reads any manifest and drives the
shared `BlockBuilder` + the dual-path DB ingestion. They belong with the
descriptor-driven RAG seam (`composer/tools/rag_env.py`, `KNOWLEDGE_BASES`) that
already lives here, so an application contributes a corpus as data rather than as
composer-resident Python.

This also settles two dangling references on this branch: `docs/rust-backend-api.md`
already pointed at `composer.scripts.rag_import` as the mechanism a wheel's corpus
arrives through, and the comment naming it in `composer/rag/db.py` had to be dropped
when the crucible registration was deferred. Both are accurate again. `KNOWLEDGE_BASES`
stays empty and `rag_env._FACTORIES` is untouched — the mechanism ships corpus-free,
and both halves of the first corpus still land with the application that declares it.

`docs/rag-import-format.md` comes along, rewritten where it assumed the Crucible app
was present: §4's registry example is empty rather than seeded, §7 lists what the
mechanism ships instead of what Crucible did with it, and the links into
`rust/crucible-app/` are gone. Crucible remains named as the first adopter, with its
own corpus documented on its own branch.

Tests: new tests/test_rag_import.py — the importer had no coverage. Pins both indexes
being fed, `part` numbering across sections and across manifests sharing a DB (the
`(headers, part)` unique key spans both), per-section code-ref tagging, the long-section
split, and the version / unresolvable-target refusals. It needs spaCy transitively via
`text_processors`, so it `importorskip`s like `test_rustapp` does for its wheel.

pyright 0 errors; 583 passed, 11 deselected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the rest

The five rust-* design notes were written as successive proposals, each
superseding the last: the IoC decider loop, the passive-service API that
replaced it, the pure-app seams that made Crucible descriptor-driven, and the
PyO3 tier survey behind all of it. What shipped is the union of the last three,
so four of the five documented code that no longer exists — `RustSession`,
`resume`, `Command`/`Observation`, `Effects`, `drive_session` — alongside a
`composer/crucible/` package and a `rust/crucible-app` crate that are no longer
in this tree at all.

Replace them with one reference for the seam as built: the ten callouts, the
descriptor, the run end to end (preflight ∥ analysis, the staged setup artifact,
the fused author→validate loop), the in-loop judge, target-shared verdicts, the
two chain seams, confinement, and what a new application actually writes. Only
current design; the proposals, tier surveys and work breakdowns are dropped.
Section numbers are stable so code comments can cite them, and every inbound
reference is repointed.

The three docs that survive on this branch had drifted against the same
refactors, so correct them too rather than leave them contradicting the new one:

- application-abstraction: the per-app `run_*_pipeline` wrapper it documents is
  gone — both apps now go through the shared `cli_pipeline` and its
  continuation, the ecosystem is an explicit driver argument, and both phase
  enums grew a discovery phase.
- formalization-abstraction: `Formalizer`/`PreparedSystem`/`ComponentOutcome`
  are all generic over the unit type, the driver's data types live in ptypes,
  and `StagedFormalizer` — half of what `prepare_formalization` may return —
  was missing entirely. Line-number citations had drifted; replace them with
  symbol names so they can't drift again.
- command-sandbox: the mechanism is accurate, every consumer reference was not
  (`RealEffects`, `warm_cargo_cache`, `build_program`, the Crucible store and
  repo resolution), and it linked to a doc and two gate tests that aren't here.

Also fix two code comments that repeated a doc claim shown to be false: header
paths are neither left-packed nor truncated (`_normalize_head` maps position to
column and raises past six), and `run_local_command` no longer backs a
`RunCommand` effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pass over the commentary this branch added, against the rule that a comment
should carry intent, constraints, trade-offs, warnings or domain context — and
that change rationale belongs in a commit message rather than in the source.

Three things came out of it.

Most of the edits are the same shape: commentary that narrated the change
instead of the code. "was the Rust sessions' SETUP/PC_MAX_ATTEMPTS", "it used to
be JSON-dumped, which handed the caller the literal string \"null\"", "the same
routing the old RealEffects.emit used", "fully expresses what Crucible used to
need a subclass for", "replacing the old per-app build_crucible_env", and the
nine test files carrying the same shape. Each is now a present-tense statement
of the invariant, which is what a reader needs and what stays true.

Two comments were also simply wrong. The example app claimed ReportBackend was
`prover | foundry | crucible` and that the null Solana backend borrows a tag —
the set is `prover | foundry | none` and that backend uses "none". And
push_custom_update named "the Rust IoC loop" as its caller, which this branch's
own SDK docs say does not exist; it is the author->compile->validate loop.

The rest is editorializing that argued for the design rather than describing it:
the Review type's case against the (bool, str) it isn't, the annotation NOTE
that litigated the repo's `from __future__` policy (kept as a warning about the
runtime introspection that actually constrains it), and program_crate_of's
parameter-shape justification.

Left alone: MAX_REVIEW_ROUNDS and _parse_judge's fail-open note. Both are long,
but both warn about behaviour the code cannot express — an unwinnable review
loop, and an advisory gate that deliberately passes what it can't parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dev` includes `apps`, so CI's `uv sync --group test --extra prover` now builds
`rust/example-app` and `rust/run-confined`. uv builds path deps concurrently and
rustup's install path is not concurrency-safe: whichever maturin -> cargo call
finishes first clears $RUSTUP_HOME/downloads, and the other dies renaming its
half-downloaded component. Deterministic on a runner, invisible locally, where
the toolchain is already installed:

    error: component download failed for clippy-x86_64-unknown-linux-gnu:
      could not rename 'downloaded' file from '.../<hash>.partial' to
      '.../<hash>': No such file or directory (os error 2)

pytest's job wants those wheels — without them tests/test_rustapp.py and the
launcher suites skip themselves — so install the toolchain in a step of its own
first and let the concurrent builds find it present. Cargo's own locking handles
the rest. `rustup toolchain install` with no argument reads the channel, profile
and components from rust-toolchain.toml, so the pin stays in one place. The
cargo/target cache keeps each PR from recompiling pyo3 from scratch.

The nightly integration job would have broken the same way, and nothing under
`expensive` or `fuzz` touches the Rust artifacts, so it gets `--no-dev` instead —
on the `uv run` calls too, since a bare `uv run` re-syncs with the default groups
and pulls `apps` back in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two overlaps needed resolving beyond the textual merge.

`pyproject.toml` package-data: master widened `templates/*.j2` to
`templates/**/*.j2` to ship the template subdirectories; this branch had
independently added both that glob and `templates/*.js`. setuptools globs
package-data with `recursive=True`, so `**` already matches the top-level
files and the separate `templates/*.j2` entry was redundant — kept master's
glob plus the `.js` entry this branch needs for `prism-cvl.js`.

The design doc became optional on master (#118): `resolve_design_doc`
returns `None` instead of raising, `SourceCode.content` is nullable, and
`SourceCode` no longer derives from `SystemDoc`. The Rust entry point
resolves its own design doc, so it had the same fail-fast assumption and
its own copy of the root cache key. It now degrades to a source-only run
the same way, and `_root_cache_key` delegates to `pipeline.cli.root_cache_key`
so the no-doc sentinel is defined once rather than in two places; the Rust
side keeps only its truncation. Nothing on the Rust, Solana, Foundry or
pipeline paths dereferences the doc content, so component analysis remains
its sole consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`requires` said `setuptools>=61.0`, which is lower than either real
constraint. The `templates/**/*.j2` package-data entry needs 62.3+ — the
release where package-data globs gained `recursive=True`. Verified by
bisecting the wheels: 62.2 and earlier expand them with a bare
`map(glob, patterns)`, where `**` collapses to a single `*`, so the entry
would match only the subdirectory templates and drop all ~92 top-level ones
without any error.

That floor turns out not to be the binding one. Every setuptools below 67.0
imports `pkgutil.ImpImporter` via `pkg_resources`, which 3.12 removed, so
none of them run on the `requires-python = ">=3.12"` this project declares —
the silent-drop build was unreachable in practice rather than latent. 67.0 is
the lowest release that both runs on 3.12 and globs recursively, confirmed by
building a fixture wheel against it and checking a top-level template, a
subdirectory template and `prism-cvl.js` all ship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The crate was one 1000-line file whose section banners already marked the
seams; this turns each into a module. Items move verbatim and lib.rs
re-exports every one of them, so the public API and the `$crate::…` paths
`export_app!` expands to are unchanged.

SandboxGrants moves next to WorkspacePrep rather than into sandbox.rs:
both are pure declarations the host acts on, while sandbox.rs is the half
that spawns a process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`finalize`, `validate_preconditions` and `sandbox_grants` each took a
`serde_json::Value` the backend hand-dug with string keys. The shapes were
real contracts — wire.py even noted its `FinalizeInput` was "the only written
definition" of one — so this writes them down in Rust:

* `FinalizeInput` / `FinalizeComponent`, mirroring the model the host already
  sends. `delivered: bool` beside always-present fields becomes
  `ComponentOutcome::{Delivered, GaveUp}`: a component that gave up has no
  spec, no targets and no rows, so there is nothing to read past its name.
* `AppArgs` for the two argument-shaped callouts. `program` and `source_path`
  are split from `path:Name` by the host, so no wheel re-splits it, and the
  grants call now gets the same payload the precondition check does (it was
  missing `program_crate` entirely).

`ProgramCrate` is resolved at the FFI boundary, so the partly-empty shape a
host that resolved nothing sends never reaches a callout. It was previously
each backend's job to remember `resolved()` — crucible calls it in five
places, and any one of them could have been forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`kind: String` was a discriminator the SDK documented as a closed set of
three, and every backend branched on it with string compares. Worse, it
silently changed what the neighbouring fields meant: `component` was the
analyzed model on a setup turn, the unit on a component turn, and nothing on
a preflight. It is now `Authored::{Preflight, Setup{model}, Component{unit}}`
— flattened, so the wire shape is unchanged — and neither turn can be asked
for the other's payload.

The `context` blob goes with it. Two of its keys were pure indirection
between the SDK and itself:

* `SetupSpec.context_key` — the wheel declared the key, the host echoed the
  artifact back under it, and the wheel read its own string. It is now
  `AuthorInput.setup`, and the field is gone from the descriptor.
* the `idl` key, whose presence meant "the file is in place" — now
  `AuthorInput.idl: Option<String>`, which says that in the type.

What remains (the wheel's own declared flags) becomes `AuthorInput.args`,
the same values `AppArgs.declared` carries, read through `DeclaredArgs::get`
instead of each wheel writing its own `ctx_str`/`ctx_u64` accessors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trait said `unit: &str` and "check ONE unit"; the host has always passed
a *target*, which several report rows can share. So the parameter was
misnamed, and a backend's first move was to recover what the host already
knew — crucible re-derives `self.units(input)` and filters it by name.

`validate` now takes a `Target { name, units }`. `Target::all` and
`Target::verdicts` build the outcome from it, so the unit names a verdict is
keyed by come from the units the host sent rather than being spelled again
by each backend.

Adds the first test to drive the validate seam at all: one run per distinct
target, each carrying exactly its rows, and every covered row's verdict
reaching the result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PreflightSpec`/`SetupSpec` were `{phase_key, label}` structs pointing at a
declared phase by string key and repeating its label — crucible writes
"preflight"/"Build Preflight" twice and "harness_fixture"/"Harness Fixture"
twice — with nothing validating that the key names a real phase (a typo was
a KeyError mid-run). `PhaseSpec.core_slot` already did this job properly for
the four driver phases, so it widens to a `role` covering all of them:
grouping (the default), the four core, discovery, preflight, setup.

A role no phase claims is a step the application doesn't have, which is what
`AppDescriptor::step(role)` returns None for. Two structs and a cross-
reference by string key go away, and a step can no longer name a phase that
does not exist.

`ArtifactLayout.deliverable_primary` — documented as "ignored PerComponent" —
moves onto `DeliverableMode::Callout`, the only mode it means anything under.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`compile`/`validate` took `workdir` and `sandbox` as a loose pair that every
call then handed straight back to `run_confined` — so they become one
`Workspace { dir, sandbox }` with a `run` method, and the "call this from
inside allow_threads" contract is structural rather than a doc note. `run`
also takes any `IntoIterator<Item: AsRef<OsStr>>`, which drops the
`vec!["run".to_string(), …]` ceremony from every call site.

`Property.sort` was a free string documented as a closed set of three, next
to `Outcome`, which is an enum for exactly that reason. It is now
`PropertyKind`, mirroring the host's shared `PropertyType`.

`Verdict::with_detail` covers the case a backend actually has — an
`Option<String>` parsed out of tool output — instead of forcing a mutable
struct update between two constructors.

`EventKind`'s doc pointed at a `Command::Emit` that no longer exists on the
Rust side. It now says who really emits: the host, around the callouts it
drives. A wheel has no emit channel at all, so a declared kind nothing emits
renders nothing (crucible declares two such kinds today).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The crate root re-exported all ~30 items, so every type had two paths and
the modules were just filing, not namespace. Only two things stay at the
root: `Backend` (what every app names, and `backend` holds nothing else)
and the `pyo3` re-export the macro needs.

With the module as the namespace, the `ffi_` prefix was stutter, so those
ten helpers become `ffi::descriptor`, `ffi::compile` and so on. The macro
expansion is the only caller, so no wheel spells them either way.

The example app now imports per module, which is the shape a real backend
gets: authoring / descriptor / outcome / sandbox, one line each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The offline half of the build sandbox did not work. Cargo parses this env
var as a config boolean and takes `true`/`false` only, so `=1` fails the
build with

  error in environment variable `CARGO_NET_OFFLINE`:
  provided string was not `true` or `false`

and — the part that matters — it fails that way *from the network path*,
having already tried to update the registry. A truthy-looking value was
worse here than no value at all: it neither forced offline nor left a
working online build.

Found while reconciling the docs with eric/crucible-app, which had already
changed the value in code but kept the `=1` wording in the docstring beside
it. The docstring and the doc now say why the spelling is load-bearing, so
nobody "simplifies" it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ericeil
ericeil marked this pull request as ready for review August 5, 2026 19:13
from composer.ui.pipeline_app import NatspecPipelineApp
from composer.cli.natspec_startup import build_mental_model, make_source_factory

_log = logging.getLogger(__name__)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my guy is in the pocket of big logging

Comment thread composer/io/context.py
Comment on lines +142 to +144
def push_custom_update(
payload: Mapping[str, Any], *, thread_id: str, checkpoint_id: str = ""
) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without even seeing how this is used, I can almost guarantee I'm going to hate it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reader: I hated it so, so much

Comment thread composer/pipeline/core.py

# 1. System analysis (shared primitive; the ecosystem supplies the analyzed model type,
# prompts, validation, and front-matter — EVM reproduces prior behavior exactly).
analyzed = await run.runner(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

Comment thread composer/pipeline/core.py
Comment on lines +307 to +313
try:
preflight = await preflight_task
except BaseException:
analysis_task.cancel()
await asyncio.gather(analysis_task, return_exceptions=True)
raise
analyzed = await analysis_task

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just asyncio.gather(preflight_task, analysis_task) I think failure of one of analysis or preflight automatically cancels the other during a gather, I might be misremembering though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently we want TaskGroup here

Comment on lines +57 to +62
The run's semaphore budgets *concurrent agents* (``--max-concurrent``, default 4). A build
spends CPU and wall-clock, not model calls, so charging it to that budget quietly takes away
concurrency the user asked for: a ten-minute cargo build would hold one of the four slots for
the whole phase it overlaps. The task is otherwise identical — same handler, phase, and
lifecycle events — it is just not counted."""
return await run_task(factory=self._handler_factory, fn=job, info=task_info)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

except this runs into the opposite problem. We likely want to throttle CPU bound work too. The containers we run this on are relatively small, so multiple "we're offloading CPU computation to another process" running concurrently without a sempahore is its own problem waiting to happen.

I'm not suggesting we go full kotlin coroutines with different dispatcher pools for Unconfined, IO pools, etc. But surely some middle ground here.

Comment thread composer/rustapp/entry.py

# argparse Namespace duck-types the ModelConfiguration protocol (the model flags come from
# ExtendedModelOptions); the built-in entries cast their args the same way.
tiered = get_provider_for(tiered=cast(Any, args))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, why cast to Any? You're already importing TieredModelProviders, just cast to that?

Comment thread composer/rustapp/entry.py
Comment on lines +401 to +404
return await run_pipeline_fn(
source_input=source_input, ctx=ctx, handler_factory=handler,
env=env, args=args,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh! how interesting. all that just to bypass the pipeline???

Comment thread composer/rustapp/host.py


def load_module(module_name: str) -> RustAppModule:
"""Import a Rust application's compiled module by name (e.g. ``"echoprover"``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"compiled"????

we are talking about python here right?

Comment thread composer/rustapp/host.py
only ever uses phase members for ``.name`` and as dict keys (no isinstance /
identity checks against a static class)."""
ordered = descriptor.ordered_phases()
name = "".join(part.capitalize() for part in descriptor.name.split("_")) + "Phase"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this just descriptor.name.capitalize().replace("_", "") + "Phase"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — str.capitalize() uppercases only the first character of the whole string and lowercases the rest, so it never touches the letters after the underscores

Comment thread composer/rustapp/host.py
Comment on lines +296 to +300
phase = build_phase_enum(descriptor)
core = build_core_phases(descriptor, phase)
ordered = descriptor.ordered_phases()
phase_labels = {phase[p.key]: p.label for p in ordered}
section_order = [p.label for p in ordered]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait, this looks familiar. Why are we building these phase/core things in two different places. Seems fishy

@jtoman

jtoman commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

the rest of the review to continue tomorrow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants