* perf(build): codify CI's debug=0 policy as the workspace dev/test/release profile A handful of lanes (reborn-tests.yml's 10 jobs via workflow env, reborn-e2e.yml's rust-reborn job, platform-and-compat.yml's hooks-parity-tests/windows-build jobs, coverage.yml's coverage job, one live-canary.yml step) already build dev/test at CARGO_PROFILE_DEV_DEBUG=0/ CARGO_PROFILE_TEST_DEBUG=0 via duplicated env decls; this is a true no-op for them. Every other Rust-building job in the repo (code_style.yml's clippy/test jobs, reborn-playwright.yml, sccache-dist-smoke.yml, release-plz.yml, nightly-deep-ci.yml, and most of ironclaw-stress.yml/ live-canary.yml) has never set this env and builds at cargo's full-debug dev default today, so this task changes their build fingerprint and costs each a one-time Swatinem/rust-cache cold miss on its next run. Chosen deliberately: this pays that cost once, up front, for the whole repo, rather than smearing it across the 13 file-swap commits that follow. ironclaw-stress.yml also separately restated CARGO_PROFILE_RELEASE_DEBUG=0 three times (already cargo's built-in release default, and now explicit here too) — release build output is unaffected either way. Measured on ironclaw_common tests: debug=0 175 MiB vs line-tables-only 257 MiB vs full 272 MiB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: add the setup-rust composite action (unused by any workflow yet) One component for what toolchain, linker, and profile env a Rust CI job gets: installs via the pinned dtolnay/rust-toolchain SHA, then exports RUSTUP_TOOLCHAIN from the action's own resolved-toolchain output so a job's cargo invocations can never drift from what this step actually installed — no separate mechanism needed for nightly lanes. Optional mold: true absorbs the install/verify/RUSTFLAGS-export steps currently copy-pasted per job. No workflow calls this yet (following tasks swap each file one at a time), so this cannot change CI behavior. New ws12 structural check on the action file itself; self-tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(sccache-dist-smoke): install Rust via the setup-rust composite No behavior change: the composite defaults to the same 'stable' toolchain dtolnay/rust-toolchain already defaulted to; this job now also gets RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(reborn-playwright): install Rust via the setup-rust composite No behavior change: same default toolchain, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release-plz): install Rust via the setup-rust composite No behavior change: same default toolchain, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(nightly-deep-ci): install Rust via the setup-rust composite No behavior change: same default toolchain, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(reborn-release-compile): install Rust via the setup-rust composite No behavior change: same default toolchain and the same matrix.target input, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(ironclaw-stress): install Rust via the setup-rust composite No toolchain behavior change: same default toolchain, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Also deletes the three CARGO_PROFILE_RELEASE_DEBUG=0 env decls, already no-ops restating cargo's own release-profile default and now Cargo.toml's explicit [profile.release] debug = 0 (landed in a prior commit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(code_style): install Rust via the setup-rust composite No behavior change: same default toolchain and the same clippy/rustfmt component inputs, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(platform-and-compat): install Rust via the setup-rust composite No toolchain behavior change: same default toolchain and the same wasm32-wasip2 target input, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Deletes two now-redundant CARGO_PROFILE_DEV_DEBUG/CARGO_PROFILE_TEST_DEBUG=0 pairs, no-ops since Cargo.toml's [profile.dev] debug = 0 landed. Also removes the wasm-wit-compat job's now-empty `env:` key left over once both its lines were deleted (a dangling null mapping is valid YAML but dead config; no functional change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(reborn-e2e): install Rust and mold via the setup-rust composite No behavior change: rust-reborn keeps mold (now via mold: true — same apt install, same verify commands, same canonical RUSTFLAGS prefix, moved into the composite); webui-v2-smoke keeps the same default toolchain. Both now get RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml. Deletes the now-redundant literal mold RUSTFLAGS string and CARGO_PROFILE_*_DEBUG pair. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(coverage): install the nightly toolchain via the setup-rust composite No behavior change: both jobs pass the same explicit toolchain: nightly-2025-11-01 / components: llvm-tools-preview / targets: wasm32-wasip2 as before, now through the composite, which pins RUSTUP_TOOLCHAIN to that exact spec — the same protection the prior design gave these two lanes via a hand-paired job env var, now automatic for every composite call. Deletes the now-redundant CARGO_PROFILE_*_DEBUG pair in the coverage job. check-reborn-branch-coverage-flags.py and reborn_coverage_lane_stack_headroom.rs both still pass (their pinned strings are untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(reborn-tests): install Rust and mold via the setup-rust composite No behavior change to toolchain resolution (same defaults/nightly pins as before, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml) or to the mold linker's effective RUSTFLAGS (the composite's mold: true prepends the canonical prefix onto each job's existing RUSTFLAGS, so the two nightly jobs' -Zcrate-attr suffix is preserved byte-for-byte). Absorbs 7 duplicated mold-install steps and 2 duplicated 'Verify mold linker' blocks into the composite. Two disclosed minor deltas in qa-recorded-fixtures: mold install now includes clang (already present via the runner image; matches the other 6 sites) and now runs after, not before, Install Rust. Deletes the workflow-level mold RUSTFLAGS/ CARGO_PROFILE_*_DEBUG trio, now no-ops. CARGO_INCREMENTAL lines and RUST_MIN_STACK lines are untouched. check-reborn-branch-coverage-flags.py and reborn_coverage_lane_stack_headroom.rs both still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(live-canary): install Rust via the setup-rust composite No toolchain behavior change: same default toolchain and the same 7 wasm32-wasip2 target inputs, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Deletes the one now-redundant CARGO_PROFILE_DEV_DEBUG='0' step env, a no-op since Cargo.toml's [profile.dev] debug = 0 landed. CARGO_INCREMENTAL is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(qa): drop the now-redundant CARGO_PROFILE_DEV_DEBUG setdefault Cargo.toml's [profile.dev] debug = 0 is now the workspace default, so this QA harness's env setdefault restates it for no reason. CARGO_INCREMENTAL is untouched — incremental compilation stays a per-caller decision, not a Cargo.toml profile setting (same reasoning applies to the sibling decl in scripts/reborn_qa_matrix/run_hermetic_qa.py:101, left alone). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * build: pin the local Rust toolchain via rust-toolchain.toml Pins exactly what CI's composite-installed toolchain already resolves today (1.98.0 + clippy/rustfmt), so nothing rebuilds and rust-cache keys hold. Safe now that every CI job installs Rust through .github/actions/setup-rust (landed across the prior 13 commits), which exports RUSTUP_TOOLCHAIN and so cannot be overridden by this file landing. Local clippy/rustfmt now match CI's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(agents): document the toolchain pin and bump process AGENTS.md is scanned by check-guidance.py; .github/workflows/README.md is not (zero hits under ROOT_GUIDANCE/CRATE_GUIDANCE_BASENAMES), so this is the enforced location for the bump instructions Task 19's ws12 guard assumes contributors can find. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(gate): forbid direct dtolnay/rust-toolchain use; enforce the toolchain-pin sync New contracts: no workflow may call dtolnay/rust-toolchain directly (must go through .github/actions/setup-rust) or write out the canonical mold RUSTFLAGS prefix by hand (must pass mold: true); rust-toolchain.toml's channel and the composite's default toolchain input must name the same version. Both are simple negative/equality checks, not per-site window scans — collapsed from the abandoned per-input design specifically because every job now routes through one component. Sabotage tests included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): let the composite own RUSTFLAGS so job env cannot shadow mold A job-level `env: RUSTFLAGS:` is re-applied to every step of that job on top of whatever earlier steps wrote to $GITHUB_ENV, so it shadows the setup-rust composite's export for the rest of the job — silently dropping the mold linker flags. That hit the two heaviest lanes: crate-tests (the required PR lane and the measured critical path) and reborn-integration-coverage, both of which declared RUSTFLAGS for their nightly `-Zcrate-attr` features. The pre-composite code manually pasted the mold prefix into exactly those two job envs despite the workflow-level env already carrying it, which is the historical symptom of the same shadowing. The composite now takes an `extra_rustflags` input and composes the whole value (mold flags, then the job's flags, then anything already exported), so no job-level declaration is left to shadow it. Both reborn-tests jobs and both coverage.yml jobs pass their crate attributes through the input instead; zero job-level RUSTFLAGS keys remain in the tree. Install Rust precedes every cargo step in all four jobs, so the export covers them. `validate_no_job_env_rustflags_with_setup_rust` pins the invariant: a job installing Rust through the composite may not declare its own RUSTFLAGS. It found the two coverage.yml jobs that this fix would otherwise have missed. Three sabotage tests plus a live-tree assertion cover it. Note for review: GitHub's documentation does not state the $GITHUB_ENV-vs-job-`env` precedence explicitly. This change is correct either way — with no job-level key there is nothing to shadow under any precedence rule — so the fix does not depend on resolving that question. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): map .github/actions/setup-rust/ into the Reborn PR test planner Detect Reborn test scope failed on this PR's own head commit: the planner's fail-closed arm for .github/actions/** had no rule for the new setup-rust composite, so it raised "unmapped test or CI path" and red the whole Tests (Reborn) roll-up. Every Tests (Reborn) job now installs Rust through this composite, exactly like the existing setup-sccache-dist entry in SHARED_REBORN_ACTION_PREFIXES, so it belongs in the same fail-safe-to-full bucket: no narrow lane can exercise a change to it, and a change here means run the exhaustive plan. Generalized the reason string and its test assertion from "shared sccache action changed" to "shared reborn action changed" since the bucket now covers two actions, not one; this is a text-accuracy fix, not a weakened check (still asserts full mode / all partitions / all lanes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): let has_code see the toolchain pin and its own guard validate_toolchain_pin_sync() only runs inside fast-checks, gated on has_code || has_guidance. The has_code regex enumerated crates/, tests/, Cargo.toml, and friends but not rust-toolchain.toml or .github/actions/setup-rust/ — so a PR touching only the two files the sync guard exists to police got has_code=false, has_guidance=false, and the guard never ran. Add both paths to the has_code regex, and pin them into CRATE_SCOPE_FILTERS' has_code in_scope probes so a future narrowing of the grep fails validate_crate_scope_filters loudly instead of quietly dropping them again. Regression: added a dedicated test asserting rust-toolchain.toml and .github/actions/setup-rust/action.yml are in has_code.in_scope, and confirmed test_checked_in_scope_filters_pass failed against the old regex before fixing .github/workflows/code_style.yml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): pin the mold verify step's Linux guard in the setup-rust contract validate_setup_rust_action() checked the Linux `if:` guard on the mold install and export steps, but the "Verify mold linker is active" step in between had no constant and no check at all — an unguarded verify step would run the mold link check on any runner OS (where mold and clang are never installed) and nothing would catch it. Add MOLD_VERIFY_STEP to the guarded set validate_setup_rust_action checks. Regression: added test_missing_mold_verify_linux_guard_fails and test_missing_mold_verify_step_fails, confirmed both failed with the old two-step tuple before adding MOLD_VERIFY_STEP to it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): anchor the toolchain-default regex to the toolchain input block validate_toolchain_pin_sync() used `re.search(r'default:\s*"([^"]+)"', action_text)` over the WHOLE setup-rust action.yml, resolving to the first non-empty `default:` in the file. That is only ever the `toolchain` input's default by accident — every other input's default is an empty string today and sits after it. A reordered or added input with its own non-empty default before `toolchain:` silently redirects the guard onto the wrong value, hiding real drift between rust-toolchain.toml and the composite. Add input_body(), a step_body-style helper that bounds one action.yml `inputs:` entry by the next input heading, and scope the default-value search to the `toolchain:` input's own block. Regression: added test_a_reordered_earlier_input_with_a_non_empty_default_cannot_hide_drift, confirmed it failed against the old whole-file regex (a decoy default matching the pinned channel hid an actual drift in toolchain's own default) before scoping the search to input_body(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): catch workflow-level env RUSTFLAGS shadowing setup-rust too JOB_ENV_RUSTFLAGS matched only six-space job-level `env:` indentation (jobs.<job>.env.RUSTFLAGS). A workflow-level top `env:` block shadows the composite's mold export identically — GitHub re-applies it to every step of every job the same way it re-applies job-level env — but sits at two-space indentation before any job heading, so it was invisible both to the indentation-bound regex and to the per-job block slicing (which only starts scanning at each job heading). Add a WORKFLOW_ENV_RUSTFLAGS check over the file's preamble alongside the existing per-job scan. Also add the missing PER-JOB skip coverage: the only existing test for `SETUP_RUST_USES not in block` exercised the FILE-level skip (`SETUP_RUST_USES not in text`) via a single-job file. Added a same-file two-job test where one job uses the composite and a sibling does not, to prove the sibling's own RUSTFLAGS stays allowed. Regression: added test_workflow_level_rustflags_alongside_setup_rust_fails, confirmed it failed before adding the WORKFLOW_ENV_RUSTFLAGS preamble check, then restored it green; also added test_sibling_job_without_setup_rust_is_allowed_in_a_multi_job_file to pin the previously-untested per-job skip branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ci): assert every exhaustive-plan field for the setup-rust widen test_shared_setup_rust_action_widens_to_exhaustive_plan only checked 4 of the 11 fields _full_plan() returns (mode, root_partitions, integration_lanes, and a substring of reasons[0]) — the other 7, including run_sandbox_docker and run_group_tests, were unpinned. This is ws12's own guardrail framework, so a regression narrowing this exact plan's blast radius deserved full coverage, not a partial one. Regression: widened the assertion to full dict equality against every field _full_plan() returns; confirmed it fails when run_sandbox_docker is flipped to False (a change the old partial assertion would have let through silently), then restored it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): glob both .yml and .yaml when loading workflows load_workflows() globbed only `*.yml` under .github/workflows/. GitHub Actions accepts either extension for a workflow file, so a `.yaml` workflow would silently escape every ws12 contract this loader feeds — latent today since no `.yaml` workflow exists, but a landmine for the next one added. Glob both extensions. Regression: added test_discovers_both_yml_and_yaml_extensions, confirmed it failed against the *.yml-only glob (the .yaml fixture was silently dropped) before widening the glob. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): guard every Rust bootstrap, not just the vendor action An approach audit found the migration's "single owner" claim was false and, worse, unenforceable: .github/workflows/ironclaw-release.yml:133 still bootstraps Rust with `curl https://sh.rustup.rs | sh`, so the lane that builds shipped artifacts got no RUSTUP_TOOLCHAIN pin, no mold wiring and no check against rust-toolchain.toml. validate_no_direct_dtolnay_usage greps only the literal `dtolnay/rust-toolchain@`, so that path passed the gate forever, and no test covered it. The file is live, not inert boilerplate — commit2a7d49c60hand-edited that exact line five days before this branch. That file is regenerated wholesale by cargo-dist (see [workspace.metadata.dist], cargo-dist-version 0.31.0), so migrating it onto the composite would be clobbered on the next regeneration. It is therefore an ACCEPTED exception rather than a silent one: - validate_no_unmanaged_rust_bootstrap now fails on `sh.rustup.rs`, `rustup-init` and `rustup toolchain install` in any workflow, with ACCEPTED_RUST_BOOTSTRAPS pinning ironclaw-release.yml to exactly one occurrence. A second bootstrap there, or any in a hand-written workflow, fails the gate; and if the generator stops emitting it, the count check says so instead of leaving a stale exemption behind. - AGENTS.md and rust-toolchain.toml said "every CI job" and "exclusively". Both were false as written. They now say hand-written jobs, name the exception, and point at the checker that enforces it. Proven by sabotage: adding a curl bootstrap to code_style.yml fails the gate; adding a SECOND one to ironclaw-release.yml fails it with "2 ... 1 accepted here"; both restored, gate green. Five unit tests cover the hand-written case, rustup-init/toolchain-install variants, the accepted single, the accepted-file second, and the live tree. Also drops INPUT_HEADING/input_body, a byte-identical duplicate of the existing JOB_HEADING/job_body with one call site — an action's `inputs:` entry has the same two-space `name:` shape a workflow job does, so job_body(action_text, "toolchain") does the job. Verified: ws12 self-tests 120 OK, ws12 live gate passed, planner suite 88 OK, check-guidance OK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(toolchain): name the accepted release-workflow bootstrap in the pin header The companion commit corrected AGENTS.md but this file's header still said "CI workflows install Rust exclusively through .github/actions/setup-rust" — false while ironclaw-release.yml bootstraps Rust itself, and the same overstatement the audit flagged. (The earlier edit aborted on an unrelated assertion before reaching this file.) It now says hand-written workflows, and names the one accepted exception plus the checker that pins it to a single occurrence, so the header matches what the gate actually enforces. Verified: check-guidance OK, ws12 live gate passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): migrate the release lane onto the composite and unbreak two guards An approach re-audit and a multi-agent code review both rejected the previous round's work. Three real defects, all in code added by this PR. 1. The "accepted exception" was unjustified. The claim was that cargo-dist regenerates ironclaw-release.yml wholesale, so its `curl sh.rustup.rs` bootstrap could not be migrated. The repo contradicts that four ways: `allow-dirty = ["ci"]` exists precisely so hand-edits survive regeneration (.github/workflows/README.md documents it for the permission hardening); two other hand-added steps already live in that job; cargo-dist exposes `github-build-setup` (already used for Node/pnpm in .github/dist-build-setup.yml) as the durable seam for exactly this; and `actions/checkout` and `setup-python` already run via `uses:` in that same container job, so a composite call is not structurally precluded. The bootstrap now lives in .github/dist-build-setup.yml as a `uses: ./.github/actions/setup-rust` call, re-included on every regeneration, keeping the previous step's `if: ${{ matrix.container }}` condition so release behaviour is unchanged. ACCEPTED_RUST_BOOTSTRAPS is empty: no lane is exempt, and the prose in AGENTS.md and rust-toolchain.toml states that without carve-outs because it is now true. 2. The workflow-level RUSTFLAGS check was dead code. JOB_HEADING matches any two-space `key:` line, so `on:`'s children (`push:`, `workflow_call:`) matched and headings[0] truncated the preamble at the first TRIGGER — before the real top-level `env:`. Verified on the live reborn-tests.yml: first match was `workflow_call` at offset 26, `jobs:` at 3177. Headings are now bounded to the `jobs:` block. The old unit test passed only because its fixture omitted `on:`; the new one carries a realistic trigger block. 3. The per-job check was blind to YAML aliases. release-plz.yml's `release-plz-pr` reaches the composite via `- *install-rust` and contains no literal `uses:` line, so the scan skipped it — a job-level RUSTFLAGS there would have shadowed mold silently. Anchors carrying the composite are now resolved and aliased jobs are checked. Also broadens the bootstrap guard beyond three rustup literals to known third-party toolchain actions (actions-rs, actions-rust-lang, hecrj, raftario), after the coverage lane showed the enumeration was trivially evadable. The one case text cannot see — a `container:` image shipping Rust preinstalled — is named in a comment as residual risk rather than papered over. Proven against the real files that defeated the old guards: a workflow-level RUSTFLAGS injected into reborn-tests.yml is now caught; a job-level RUSTFLAGS in release-plz.yml's alias-reached job is now caught; both were silent before. Also removes an orphaned comment left describing the deleted INPUT_HEADING. Verified: ws12 self-tests 123 OK, ws12 live gate passed, planner suite 88 OK, check-guidance OK, both changed workflows parse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): catch step-level RUSTFLAGS, not just job- and workflow-level Third gap the review found in the same guard. JOB_ENV_RUSTFLAGS matched the exact 6-space job-env depth, but GitHub also lets an individual step declare `env:` (10 spaces in this repo's workflows, e.g. reborn-tests.yml:255), and a step-level RUSTFLAGS shadows the composite's $GITHUB_ENV write for that step exactly like a job-level one — dropping the mold linker flags with no failing check, which is the precise regression the guard exists to prevent. Widened to `^ {6,}RUSTFLAGS:` so job- and step-level depths are both caught, and corrected the error message, which called a step-level key "job-level". Proven against the live file: injecting a step-level RUSTFLAGS into reborn-tests.yml's crate-tests job is now reported; the clean tree still passes, so the wider pattern adds no false positives. Worth stating plainly: this is the third hole found in this one substring-matching guard (dead workflow-level check, alias blindness, now step-level depth). Enforcement by grepping YAML text is structurally weaker than the composite's own RUSTUP_TOOLCHAIN export, which makes drift unrepresentable rather than policed. The stdlib-only constraint on scripts/ci rules out a real YAML parse here, so the residual risk is the shapes no pattern anticipated — noted rather than claimed away. Verified: ws12 self-tests 124 OK, ws12 live gate passed, planner suite 88 OK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): restore the release lane's Rust install and assert it is there The previous commit broke the release lane. It deleted the container-only `curl | sh` bootstrap from .github/workflows/ironclaw-release.yml and added the composite call only to .github/dist-build-setup.yml — but that fragment reaches the generated workflow solely through `dist generate`, which was never run. The checked-in workflow ended up with NO Rust install path at all: `grep -c 'setup-rust|Install Rust|rustup' ironclaw-release.yml` → 0. The next tagged release would have failed on `cargo: command not found` in every container matrix entry — the exact case the deleted step existed for. Two independent code-review lanes caught it (Critical/confidence 100 and High/confidence 92), both noting the same root cause: every check in this file asserts an ABSENCE — no dtolnay, no raw bootstrap, no shadowing RUSTFLAGS — so deleting a step and adding nothing read as "clean" and the whole suite stayed green over a broken release lane. The step is now in both places, deliberately: the fragment so `dist generate` keeps emitting it, and the checked-in workflow because that is the file GitHub actually runs. validate_release_workflow_installs_rust closes the class by asserting PRESENCE in both files. Proven red-first in both directions: removing the step from the generated workflow fails with the cargo-not-found rationale; removing it from the fragment fails with the regeneration rationale; restoring either passes. Four unit tests plus a live-tree assertion. This is the second time in this PR that an absence-only guard reported success over a real defect. Guards that only forbid shapes cannot notice that the thing they were protecting is gone. Verified: ws12 self-tests 128 OK, ws12 live gate passed, planner suite 88 OK, ironclaw-release.yml parses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ci): split the Rust toolchain contracts out of ws12_workflow_contracts Pure move, no behavior change. ws12_workflow_contracts.py had grown to 2,171 lines — past the repo's ~1k ceiling — mixing stress-suite parity, crate scope filters, WebUI site checks, and this PR's six toolchain validators in one file. Three separate reviews flagged it (two approach audits, one design lane). scripts/ci/lib/rust_toolchain_contracts.py now owns one question: what toolchain, linker, and build flags does a Rust job get? Six validators, their constants, and a module docstring recording the absence-vs-presence asymmetry that let a release workflow with no Rust install pass this suite last week. scripts/ci/lib/workflow_text.py owns the four generic YAML text helpers both modules need. This absorbed a latent defect: JOB_HEADING was defined TWICE in ws12 (lines 426 and 988), the second shadowing the first for every validator below it. The two patterns were `[A-Za-z0-9_-]` and `[a-zA-Z0-9_-]` — semantically identical, so nothing was wrong today, but only by luck. One definition now. The test file imports each symbol from its owning module rather than letting ws12 act as an implicit re-export, so the ownership is checkable. classify-test-scope.sh: both new paths join the reborn-scoped list. Without this the split would have silently changed CI behavior — editing a validator in its new home would classify differently than editing it in ws12 did — which is exactly the kind of quiet regression a "pure move" is supposed to not have. test-classify-test-scope.sh pins it; proven red-first by reverting the list entry and watching the new assertion fail. Verified after the move, both validators proven to still fire from their new home by sabotage: breaking the RUSTUP_TOOLCHAIN export fails the gate, and deleting the release workflow's composite step fails it with the cargo-not-found rationale. ws12 self-tests 128 OK, live gate passed, classify-test-scope 69 PASS / 0 FAIL, check-guidance OK. ws12_workflow_contracts.py: 2171 -> 1833 lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(agents): point the toolchain pin at its owner instead of restating it The AGENTS.md paragraph and rust-toolchain.toml's header had grown into two near-verbatim copies of the same rationale — rustup precedence, the nightly coverage-lane carve-out, the cargo-dist fragment, why Docker is unaffected. Two copies of a rationale drift; the one nobody edits goes stale silently. AGENTS.md keeps what an agent needs to act: the pin is the source of truth, never install Rust in a workflow directly, and bumping is a two-place edit in one PR. The why lives in rust-toolchain.toml's header, where it sits next to the value it explains. 17 lines -> 11. Also refreshes the enforcement pointer to the module that now owns those checks after the split, and mentions the two guards added since this paragraph was written (release-lane presence, RUSTFLAGS shadowing). Verified: check-guidance OK (2603 path references, including the new scripts/ci/lib/rust_toolchain_contracts.py), ws12 gate passed, 128 self-tests OK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): require every cargo job to reach the composite, not just the release lane A review lane proved the release-lane guard was the right fix written too narrowly. It verified the escape by mutation: delete the `uses: ./.github/actions/setup-rust` step from code_style.yml's fast-checks job — no dtolnay call, no raw bootstrap, no RUSTFLAGS key — and all 128 tests stayed green. That is the identical bug the release-lane guard was added for last commit, reproduced in a different file, because "no dtolnay, no bootstrap, no shadowing RUSTFLAGS" is trivially true of a job that installs nothing at all. validate_rust_jobs_reach_the_composite generalizes the rule: any job whose steps invoke cargo/rustc/rustup must reach the composite, directly or through a YAML alias. Scoped to jobs that actually run the compiler, so docs and frontend jobs need no exemption, and `Cargo.toml` in a `paths:` filter is not an invocation. It ships with NO allowlist because it needs none — all 31 cargo-running jobs in the tree satisfy it today. An entry here would mean a lane building Rust on whatever toolchain the runner image happens to ship. Proven red-first on the reviewer's exact mutation: the release-only guard reports 0 errors on it, the new one names all three affected jobs (fast-checks, clippy, clippy-windows). Six unit tests including the alias path and a live-tree assertion. Also in this commit, both from the same review: - workflow_text.py still defined JOB_HEADING twice. My split moved the duplicate instead of removing it, so the commit message claiming consolidation was wrong. One definition now, and a test asserts each pattern is bound exactly once — the shadowing itself is now what fails, not just today's instance of it. - The module docstring claimed five validators assert an absence. Three do. The other four assert presence or equality and are deletion-safe for their own subject. An inaccurate tally in the very docstring warning about this asymmetry is worth more than a typo. The job/anchor scan the RUSTFLAGS validator had inline is now shared with the new check (_composite_anchors / _job_blocks / _reaches_composite) rather than written twice. Verified: 137 self-tests OK, live gate passed, check-guidance OK, classify-test-scope 70 PASS / 0 FAIL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): classify the cargo-dist fragment so the plan step stops failing closed This PR's own CI was red on `Build affected-area test plan`, and the cause was this PR: it edits `.github/dist-build-setup.yml`, the planner had no rule for that path, and the planner fails closed on anything unclassified. The step died before scheduling a single lane — so a PR touching only CI plumbing could not report at all. Reproduced locally with the exact CI invocation over the PR's real changed-file list: "Reborn PR test planner failed: unclassified pull-request path: .github/dist-build-setup.yml". Green after the fix, plan mode `full`. The fragment is workflow source that lives outside `.github/workflows/`: cargo-dist re-inlines it into `ironclaw-release.yml` on every `dist generate`. So it is the same static control as the file it becomes, and no Reborn lane reads it. That is exactly the `pull_request_template.md` gap the suite already pins, one directory over. A sweep test over the surfaces that make CI run then found a second, older gap: `.github/actions/install-cargo-component/action.yml` has been unmapped since it was introduced, so any PR editing it hits the same dead plan step. It is consumed by coverage.yml and platform-and-compat.yml, so it takes the exhaustive plan like its two siblings — the deliberate mapping that arm's own comment asks for. The sweep is scoped to `.github/actions/**` plus the fragment, NOT all of `.github/`. Fail-closed on an undecided path is this planner's intended behaviour and an existing test pins `.github/labeler.yml` refusing on purpose; a wider sweep contradicted it. The line is between config someone may leave undecided and source a job runs. Reported, not silently mapped: `.github/labeler.yml` and six `.github/scripts/*` helpers still refuse. `ci-job-result-ok.sh` is invoked by workflows, so it is a latent break of the same class — but mapping it is a policy call for the planner's owner, not a drive-by in this PR. Both fixes proven red-first: removing either mapping fails the suite, restoring either passes. Verified: planner 90 OK, ws12 137 OK, ws12 gate passed, staged-paths 4 OK, classify-test-scope exit 0, check-guidance OK, and the previously-red CI command now exits 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ci): close the three open audit findings All three were NORMAL, none blocking; closing them so review sees the design rather than a list of known nits. **One owner for the debug-info policy** (converged finding, reported independently by both system-audit lanes). The migration deleted these env pairs from five workflow job envs on the strength of Cargo.toml's `[profile.dev] debug = 0` owning the value — but left the identical `:-0` defaults standing in scripts/ci/quality_gate.sh and reborn-local-coverage-ratchet.sh. The PR's own claim of a single owner was not true of the tree it shipped. Both removed; no behaviour change, the values already agreed, and a developer's `CARGO_PROFILE_DEV_DEBUG=2` still reaches cargo because `env` inherits what it does not override. Deleting the lines alone would just let them come back, so `validate_single_debug_policy_owner` makes it structural: no script under scripts/ may ASSIGN a CARGO_PROFILE_*_DEBUG value. Assignments only — run-hermetic-test-process.sh names the same variables in a passthrough allowlist (a `case` pattern, no `=`), which is exactly how that override survives the hermetic barrier, and matching it would break the documented escape hatch. Test files are skipped: they carry the string on purpose as fixtures, and scanning them would make the contract untestable. **No speculative escape hatch.** `ACCEPTED_RUST_BOOTSTRAPS` was a per-path override with symmetric over-use AND under-use validation, plus a test pinning it empty — built in full for a case the module's own comment says does not exist. Removed; the rule is now unconditional. If an unavoidable bootstrap ever appears, the hatch gets added then, with that lane as its first entry. The test that asserted the dict stays empty now asserts the mechanism is gone rather than merely empty. **One job-boundary walk.** `_job_blocks` reimplemented the slice-between- consecutive-headings logic that `extract_job_block` already did in the same subsystem. `job_blocks()` in workflow_text.py is now the single primitive: `extract_job_block` filters it to one named job and keeps its exactly-one refusal, and the toolchain contracts enumerate it from the `jobs:` offset. That offset is load-bearing and stays — JOB_HEADING matches any two-space key, so an unbounded walk treats `on:`'s children as jobs. Verified: ws12 141 tests OK, live gate passed, planner 90 OK, staged-paths 4 OK, classify-test-scope exit 0, check-guidance OK, both edited shell scripts pass `bash -n`. The debug-policy guard proven red-first by restoring one deleted line and watching the gate name it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address PR review feedback (#7821) Four threads triaged as valid and fixed. Two are real guard bypasses in the module this PR introduces — a reviewer reproduced both, and both are the same species the module's own docstring warns about: a check reading text that only LOOKS like an executable step, or reading too narrow a slice of the file. - **Release check was file-wide, not job-scoped.** Deleting the composite step from `build-local-artifacts` while a decoy `# uses: ...` comment sat in an unrelated job returned no error. That matters more than it looks: nothing in ironclaw-release.yml matches a literal `cargo` (cargo-dist shells out to `dist build`), so `validate_rust_jobs_reach_the_composite` never covers that file and this was its only guard. Now scoped to the job, and every `uses:` match is comment-stripped per line. - **Anchor scope ran past its own YAML node.** `_composite_anchors` scanned from an anchor to the next anchor or job heading, so a comment mentioning the composite anywhere in that span marked the anchor as installing Rust — and a job that merely aliased it passed while installing nothing. Bounded by indentation to the anchor's own node, and comment-stripped. The legitimate release-plz alias shape keeps working (pinned by its own test). - **Root-level `env:` after `jobs:` was invisible.** A top-level mapping key need not precede `jobs:`; such a block applies to every job identically, but slicing the preamble at `jobs:` hid it while its two-space indent also dodged the six-space per-job pattern. It fell through both checks. Now scans the whole file. - **AGENTS.md overstated the contract.** My earlier commit463b02974made this paragraph MORE absolute than the one it replaced — "single source of truth" and "every job" — when there are two synchronized pins checked for equality, neither derived from the other, plus a nightly-lane carve-out and Docker outside the contract entirely. That is the exact universal-claim pattern `.claude/rules/guidance-maintenance.md` forbids. Reworded to say what is actually enforced. Test-file changes and why: - `ReleaseWorkflowInstallsRustTests` gained a `release()` helper and its fixtures now name `build-local-artifacts`. The old fixtures were a bare `jobs:\n build:` which no longer exercises a job-scoped contract. This makes the fixture match the real workflow shape; it does not relax an assertion. - `GuardBypassRegressionTests` adds four tests: one per bypass, plus one pinning that a real anchor alias still passes so the anchor fix cannot be "fixed" by rejecting legitimate aliases. All three code fixes proven red-first: reverting each one individually fails its own regression, and restoring it passes. Verified: ws12 145 tests OK, live gate passed, planner 90 OK, staged-paths 4 OK, classify-test-scope exit 0, check-guidance OK. Not addressed here, reported on the threads: the `workflow_dispatch` + local-action-resolution regression needs a design call (20+ sites, 4 workflows), and one thread's claim was already fixed earlier in this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): install Rust in the one hermetic lane that acquired it lazily Root cause of the E2E failures that have been red on every run of this branch while T2/T3/T4 from the same base are green. `webui-v2-test-lanes` runs a prebuilt binary and compiles nothing, so it never installed Rust. But `run-hermetic-test-process.sh` probes `rustc --print sysroot` to build the child PATH and exits 1 if it cannot resolve one. Inside the repo that probe hits the `rust-toolchain.toml` this PR adds, so rustup installed the pinned toolchain LAZILY — mid-lane, once per shard, racing its own component downloads: provider operation shard 2/4 failed with status 1 error: component download failed for rustfmt-x86_64-unknown-linux-gnu: could not rename 'downloaded' file ... .partial Confirmed by controlled experiment (#7852): reverting only `[profile.dev] debug = 0` and keeping everything else left E2E red, with a DIFFERENT subset of shards failing — a race signature, and it exonerates the profile block. A survey of every lane invoking the hermetic runners found exactly one in this state; the other seven already install through the composite. So this is one missing step, not a design fault — but it was invisible because the lane compiles nothing, and the whole point of this PR is that toolchain acquisition should never be implicit. `validate_rust_jobs_reach_the_composite` now treats a hermetic-runner invocation as needing a toolchain, the same as a literal `cargo` line. It could not see this before: the workflow text only names the script. Proven red-first — removing the step again fails the gate by name. I called this failure an unrelated rustup flake twice. It was neither: it was this PR's own side effect, visible in the logs from the first run. Verified: ws12 147 tests OK, live gate passed, planner 90 OK, classify-test-scope exit 0, check-guidance OK, reborn-e2e.yml parses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): realign the assertion I broke by rewording the guard message The previous commit widened `validate_rust_jobs_reach_the_composite` to cover hermetic lanes and reworded its message from "runs cargo but never reaches" to "needs a Rust toolchain but never reaches" — but an existing test still asserted the old string. Fast-checks went red, and Code Style followed it ("fast-checks failed: failure"), so both new failures had this one cause. Worth recording HOW it shipped, because the mistake was in my verification and not in the edit. The combined-validation command was: python3 scripts/ci/test_ws12_workflow_contracts.py 2>&1 | tail -3 | head -2 `tail -3` yields ["Ran N tests in Xs", "", "OK|FAILED"]; `head -2` then drops the verdict line. The output read "Ran 147 tests in 12.705s" and I took that as a pass. The suite had already been failing locally at that point — the command was constructed so it could not tell me. Validation now keys off exit codes rather than parsed tail text, so a failing suite cannot render as a passing one. Verified (exit codes): ws12 self-tests 0, ws12 gate 0, planner 0, staged-paths 0, suite-shards 0, changed-packages 0, classify-test-scope 0, check-guidance 0, docs-boundary 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): make the debug-policy guard see where the value was actually written Two review lanes independently reported that `validate_single_debug_policy_owner` cannot detect the regression it exists to prevent, and they are right. The guard scanned `scripts/**` for `KEY=value`. This PR deleted 14 `CARGO_PROFILE_*_DEBUG` lines from `.github/workflows/**`, all in YAML `KEY: value` form. So the guard covered the 3 script-side deletions and was blind to the 14 workflow-side ones — the majority of what it was written to keep deleted. A future PR re-adding a job-env pair would have passed clean. Reproduced before fixing: a scratch tree with a workflow job env containing `CARGO_PROFILE_DEV_DEBUG: 0` returned zero errors, and the regex did not match the YAML form at all. Now scans `scripts/**` and `.github/**` across .sh/.py/.yml/.yaml, and matches both `=` and `:`. Verified the widening is safe: nothing in the live tree matches, and the `run-hermetic-test-process.sh` passthrough allowlist still does not — that entry is a `case` pattern with no assignment character after the name, and it is how a developer's `CARGO_PROFILE_DEV_DEBUG=2` override reaches the child process. A test now pins that it keeps working. Proven red-first in both directions independently: narrowing the scope back to `scripts` fails the two workflow tests; narrowing the syntax back to `=` fails the same two. This is the third time in this PR that a guard I wrote asserted something narrower than the thing it was protecting. The pattern is consistent enough to name: I write the check against the case that prompted it rather than against the invariant, and the surrounding cases go uncovered. Also adds the widening test for `.github/actions/install-cargo-component/` (coverage lane, low severity). Its two sibling shared actions each had one; this path had only the `.github/actions/**` sweep, which asserts the planner does not RAISE but never checks the mode it returns — a mis-ordering could drop it to a `none` plan and the sweep would still pass. Red-first: removing the prefix entry fails the new test. Not changed, with measurements: a performance lane flagged the `Install Rust` step added to the 4 binary-only E2E shards as avoidable cost. Measured on the green run, it is 9-11s per shard on parallel runners, and it replaces a toolchain install that was already happening implicitly inside the test step while racing itself. Removing it entirely means changing the sysroot probe in run-hermetic-test-process.sh, which 8 lanes share. Not worth that risk for ~10s; recorded as a possible follow-up. Verified (exit codes): ws12 150 tests 0, ws12 gate 0, planner 91 tests 0, staged-paths 0, suite-shards 0, changed-packages 0, classify-test-scope 0, check-guidance 0, docs-boundary 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(agents): replace a universal claim with a verified, re-checkable count `.claude/rules/guidance-maintenance.md` rule 3 forbids universal claims without a count, and rule 2 asks for a one-line command a maintainer can re-run. The paragraph said "Every Rust-installing CI job goes through the composite" — an uncounted absolute. Pointedly, this sentence was introduced by MY fix for the previous universal-claim finding on this same paragraph. I removed "single source of truth" and "every job" from one clause and wrote a fresh absolute into the next. Now: "35 CI jobs across 11 workflow files need a toolchain and all 35 reach the composite today, with no allowlist", plus the command that re-derives it and the validator that enforces it. Counted at this head by walking every job block and testing for a cargo/rustc/rustup invocation or a hermetic-runner call; 35 need one, 35 reach it. Verified (exit codes): check-guidance 0, ws12 gate 0, ws12 self-tests 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): pin the release step's condition, and scope the coverage claim to it From your review (#7821 review 5012242062), Medium/86 on the release matrix. The valid core: the contract asserted the composite step's TEXT existed in `build-local-artifacts` and said nothing about WHEN it runs. `if: ${{ matrix.container }}` decides which matrix entries reach the composite at all, so it could be narrowed, widened, or dropped and no check would notice — while this PR claimed every Rust job reaches the composite. Now pinned in both the generated workflow and `.github/dist-build-setup.yml`, since a mismatch is what `dist generate` would silently apply. Proven red-first in both directions: changing the condition names the old and new values; dropping it reports `<unconditional>`. Three regressions, plus the existing fixtures updated to carry the condition (they previously wrote a bare step, so they could not have exercised this). One part of the finding I checked and do not think holds. It says a non-container release build "bypasses the new pin/export path and relies on whatever Rust the hosted runner provides". The pin is not bypassed: with `rust-toolchain.toml` in the repo, rustup resolves it when `dist build` invokes cargo on the hosted runner. What those entries miss is mold and the explicit `RUSTUP_TOOLCHAIN` export, not the toolchain version. It is also not a regression — the pre-composite step carried the identical `if: ${{ matrix.container }}`, so release behaviour is unchanged by this PR. Where the finding lands hardest is the docs, and that is my error. AGENTS.md said "35 CI jobs ... all 35 reach the composite" — true for what it counts, but the count walks `.github/workflows` only and the release lane is outside it entirely (no job there names `cargo`; it shells out to `dist build`). A reader would have taken it as universal. That is the second time in two commits I replaced a universal claim with another one. Now scoped explicitly, naming the release lane's actual shape and what the non-container entries do and do not get. Open question I am not deciding here: whether non-container release builds should install explicitly rather than resolving the pin lazily. That is a release-path behaviour change on a Track C surface, and the lazy resolution is the same mechanism that raced in the E2E lanes — lower risk here (no parallel shards within the job) but still implicit. Verified (exit codes): ws12 153 tests 0, ws12 gate 0, planner 0, classify-test-scope 0, check-guidance 0, docs-boundary 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): close four review findings on the condition and debug-policy guards Two Major, both real, both in code I added in the previous two commits. **The condition check had a false positive on the release path.** `_composite_step_condition` scanned only the lines ABOVE the composite `uses:` line. YAML mapping keys are unordered, so a step written `uses:` then `if:` is valid and equivalent — and returned None, reporting `<unconditional>` and rejecting a correct release workflow. Reproduced directly: the reordered step returned None. Now bounded by the step's own `- ` marker and the next line at or left of it, so the whole step body is scanned. Regression both ways: the reordered step passes, and a neighbouring step's `if:` is still not borrowed. **Quoted YAML keys bypassed the debug-policy guard.** `"CARGO_PROFILE_DEV_DEBUG": 0` and `'CARGO_PROFILE_DEV_DEBUG': 0` are the same mapping key as the bare form and matched nothing. Fixed with an optional quote on either side; the `run-hermetic-test-process.sh` passthrough allowlist still does not match, which a test pins. Two smaller ones on the test I added last commit, both correct: - the fail-closed case still probed `.github/actions/setup-rust/` — a leftover from mirroring the sibling test, so it re-tested the sibling's invariant instead of this one. Now probes install-cargo-component. - ISC004 implicit string concatenation in the expected `reasons` list. All three code fixes proven red-first: reverting the whole-step scan fails the reordered-step test; reverting the quoted-key regex fails both quoted forms; removing the prefix entry fails the mirrored test. Pattern worth recording, since it is now consistent. Both Major findings are the same shape as the three before them: I wrote each check against the exact shape in front of me — `if:` before `uses:`, an unquoted YAML key — rather than against the invariant, and the neighbouring valid shapes went unhandled. The guards keep being narrower than the thing they protect. External review has caught every instance; my own tests passed over all of them, because I wrote the tests from the same narrow mental model as the code. Verified (exit codes): ws12 156 tests 0, ws12 gate 0, planner 91 tests 0, check-guidance 0, classify-test-scope 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Henry Park <16583448+henrypark133@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
19 KiB
Agent Rules
Purpose and precedence
AGENTS.md is the canonical agent contract for this repository — the commands, hard invariants, and routing an agent cannot infer from the tree. It is not the full architecture specification: before changing a complex area, read the owning crate's AGENTS.md, then its CONTRACT.md or README.md when present; cross-crate behavior is specified under docs/internal/reborn/contracts/. (CLAUDE.md files are Claude Code adapters and pointer stubs; content lives here and in the files this one names.)
All product work belongs in the Reborn workspace under crates/; the shipping binary is ironclaw from the ironclaw package in crates/app/ironclaw_cli. crates/AGENTS.md is the routing map into the ten crate families. The repo skills under .claude/skills/ (ironclaw-reborn-orientation, reborn-feature, ironclaw-reborn-architecture-review, ironclaw-reborn-testing, reborn-extension-surfaces) are plain Markdown — read the SKILL.md directly if your harness does not load Claude skills. The same applies to the path-scoped rules in .claude/rules/*.md (ls .claude/rules/ for the full set): each is canonical for its topic — architecture/layer matrix, cargo features, database parity, error handling, events, guidance maintenance, lifecycle, review discipline, safety/sandbox, runtime-skill spec, testing tiers, tools and tool evidence, types and type placement. Claude Code auto-loads them by path; other harnesses must read the matching rule before changing files it covers.
Build, run, debug
cargo fmt # format
cargo clippy --all --benches --tests --examples --all-features -- -D warnings # lint (zero warnings; CI denies warnings — an unflagged run exits 0 with them)
cargo test # unit + integration suites (Postgres legs self-provision testcontainers; skipped without Docker)
RUST_LOG=ironclaw=debug cargo run -p ironclaw -- serve # run the serve binary (add tower_http=debug for HTTP logging)
The workspace-root integration feature is empty with zero consumers — a bare root cargo test --features integration adds nothing. Backend-heavy gated suites are crate-level (e.g. cargo test -p ironclaw_hooks --features integration,test-support). E2E suite: tests/e2e/AGENTS.md.
Cargo features are a last resort. A feature is a second build of the workspace, compiled and tested forever. Add one only for a heavy optional dependency, a build shape that ships with it OFF, a CI lane selector, a dev-only seam (always named test-support), or a privilege boundary — and say which in the manifest comment. Deployment shape belongs in DeploymentConfig and [storage], not #[cfg]. Full bar: .claude/rules/cargo-features.md.
Toolchain pin. The Rust version lives in two synchronized places:
rust-toolchain.toml's channel (what local cargo/clippy resolve) and
.github/actions/setup-rust's toolchain input default (what CI installs).
scripts/ci/lib/rust_toolchain_contracts.py enforces that they are equal — it
does not make either one derive from the other, so a bump edits both in the
same PR or the gate fails. 35 CI jobs across 11 workflow files need a toolchain
and all 35 reach the composite today, with no allowlist — re-check with
python3 scripts/ci/ws12_workflow_contracts.py, whose
validate_rust_jobs_reach_the_composite fails the build on a job that runs
cargo, or a hermetic runner, without it. That count covers .github/workflows
only; the cargo-dist release lane is outside it, because no job there names
cargo (it shells out to dist build). There, the composite runs for
container matrix entries only — hosted-runner entries resolve the pin from
rust-toolchain.toml themselves but get no mold and no explicit
RUSTUP_TOOLCHAIN export. That condition is itself pinned, in both the
generated workflow and .github/dist-build-setup.yml. The two nightly-2025-11-01 coverage
lanes pass an explicit toolchain: input, and Docker builds are the one path
outside this contract
(the build context excludes rust-toolchain.toml, so images stay on their
base-image toolchain). The same module also fails the build on a direct
dtolnay/rust-toolchain call, any other bootstrap, a release lane whose
build-local-artifacts job installs no Rust, and a RUSTFLAGS env key that
would shadow the composite's export. Precedence rules and the bump checklist
are in rust-toolchain.toml's own header.
Discover code before changing it
For where-is, who-calls, data-flow, and impact questions, probe the codebase knowledge graph before text search: run bash scripts/codebase-graph.sh status once; if fresh and graph tools are connected, use them; otherwise fall back to crates/AGENTS.md, crate-local guidance, and targeted rg. Verify graph claims against live code before acting. Use rg directly for configuration, prose, and fixtures. openwiki/ is generated prose — read-only, never hand-edit.
Where work belongs
External surfaces normalize untrusted requests through product adapters or ProductSurface; thread/turn services establish durable conversation state; the scheduler and run executor invoke the canonical runner/driver and agent loop; capability execution crosses authorization, approvals, obligations, host-runtime mediation, and the selected runtime lane; durable typed events feed projections and transport streams — transports do not invent state. Verify a flow from live symbols:
rg -n "SessionThreadService|TurnCoordinator|TurnRunScheduler|RebornTurnRunExecutor|CanonicalAgentLoopExecutor|CapabilityHost" crates
Crates live under a family directory (crates/<family>/ironclaw_*); enumerate them with python3 scripts/ci/lib/crate_tree.py . rather than assuming a fixed depth. Stable ownership decisions:
- Neutral authority vocabulary belongs in
ironclaw_host_api; execution does not. - Filesystem mounts/CAS belong in
ironclaw_filesystem; record grammar in the domain crate. - Durable events, projections, and transport streams are separate contracts.
- Authorization, approvals, resources, obligations, dispatch, and runtime lanes remain separate stages.
ironclaw_assistantowns product-facing orchestration andProductSurface; composition wires dependencies; WebUI owns HTTP/transport and frontend presentation.- Provider-neutral model contracts and provider implementations belong in
ironclaw_llm; wrappers delegate the complete provider trait. - Declarative extension metadata belongs in
ironclaw_extension_registry; execution belongs in runtime lanes and host mediation. - Safety scanning is
ironclaw_safety; skills areironclaw_skills; persistent memory isironclaw_memory(model toolsironclaw.memory.*). Always import from the owning crate.
The composition root assembles dependencies; it does not own domain policy — module-specific initialization stays behind factories or builders in the owning crate. If adding a dependency would point from a lower neutral crate into product or composition, stop and run cargo test -p ironclaw_architecture_tests first.
Subagent spawn creates and wires child runs only; planning, execution, capability calls, checkpointing, gates, retries, and completion continue through the existing runner/driver/executor path.
Host-trusted trigger ingress is sealed by trigger-worker-owned request minting and private conversation-owned trusted construction. Product adapters, product workflow, first-party capabilities, and host-runtime handlers use untrusted inbound requests and must not mint TrustedInboundTurnRequest or call trusted trigger submitter factories.
Module Specs
When modifying a module with a spec, read the spec first. Code follows spec; spec is the tiebreaker.
| Module | Spec |
|---|---|
crates/domains/ironclaw_llm/ |
crates/domains/ironclaw_llm/CONTRACT.md |
crates/substrates/ironclaw_filesystem/ |
crates/substrates/ironclaw_filesystem/CONTRACT.md |
crates/product/ironclaw_webui/ |
crates/product/ironclaw_webui/CONTRACT.md |
crates/app/ironclaw_composition/ |
crates/app/ironclaw_composition/CONTRACT.md |
crates/domains/ironclaw_identity/ |
crates/domains/ironclaw_identity/CONTRACT.md |
crates/kernel/ironclaw_trust/ |
crates/kernel/ironclaw_trust/CONTRACT.md |
tests/ (scenario coverage map) |
tests/AGENTS.md |
tests/integration/ |
tests/integration/AGENTS.md |
tests/support/reborn_parity_qa/ |
tests/support/reborn_parity_qa/AGENTS.md |
tests/e2e/ |
tests/e2e/AGENTS.md |
Coding and contract rules
- No
.unwrap()or.expect()in production code (tests are fine); propagate errors with context —.map_err(|e| SomeError::Variant { reason: e.to_string() })?— and usethiserrorfor error types inerror.rs. Cause-preserving constructors, themap_err(|_| …)ban, and the other silent-failure anti-patterns:.claude/rules/error-handling.md. - Keep clippy clean with zero warnings. Prefer
crate::imports for cross-module references. - Use strong types and enums for known domain shapes; raw strings belong at external boundaries. Shared types live with the contract owner — no mirror DTOs, and
ironclaw_commonis not a dumping ground. - No
pub usere-exports unless exposing to downstream consumers. - Prompt templates live in files, not Rust code: multi-line prompt strings go in a
prompts/*.mdfile inside the crate that owns the behavior, loaded viainclude_str!()(ls -d crates/*/*/prompts crates/extensions/packages/*/promptslists the owners). Single-line format strings are fine inline. - Preserve existing defaults unless the task explicitly changes them.
- All I/O is async with tokio; use
Arc<T>for shared state.
Testing discipline
- Test-first. Every feature and fix starts in the tests — pin the behavior, watch it fail for the right reason, then change the implementation. Every fix ships with a regression test.
- Consolidate, don't proliferate. Extend the test that already exercises the path; add a new test only for a genuinely distinct scenario.
- Integration-first. Production-wired behavior ships with a test in
tests/integration/, driven through the harness and asserting at a seam — neverwait_for_status(Completed)alone. Crate tier is the fallback only when that tier cannot reach the path (say why in the PR). - Test through the caller, not just the helper. When a helper gates a side effect, unit-testing the helper alone is not regression coverage — drive the call site at the integration tier or higher, and make mocks capture every argument the production caller passes.
Full rules and tiers: .claude/rules/testing.md; authoring guides: tests/integration/AGENTS.md, tests/e2e/AGENTS.md. Select tiers with docs/internal/testing-playbook.md, and complete the Test Strategy section of .github/pull_request_template.md with evidence or Not applicable: <reason> per tier.
Persistence and configuration
New persistence uses RootFilesystem/ScopedFilesystem and the mount catalog owned by ironclaw_filesystem (spec above); composition chooses concrete backends (PostgreSQL, libSQL, local filesystem) by profile. Domain stores are thin typed wrappers and never branch on backend; keep dual-backend parity via shared conformance suites (.claude/rules/database.md). Read-modify-write uses the shared bounded CAS helper, never a process-local mutex held across backend I/O.
Keep bootstrap configuration, persisted settings, and encrypted secrets as separate layers; preserve configuration precedence, secret-mediated provider resolution, and fail-closed startup. Environment variables are documented in .env.example; LLM backends in the llm spec (LlmBackendKind in crates/domains/ironclaw_llm/src/config.rs is the source of truth).
Security and runtime invariants
- Treat every listener, route, product adapter, runtime lane, container, and external service as untrusted until a typed boundary establishes otherwise.
- Do not weaken authentication, origin checks, body limits, rate limits, allowlists, approval leases, secret mediation, or redaction guarantees.
- External HTTP goes through
ironclaw_network; credentials remain host-side and are injected only through mediated runtime services. - New ingress must validate and bound the original payload before persistence, prompt construction, credential injection, or dispatch.
- Authorization, approval, reservation, dispatch, and execution are distinct stages. Do not bypass or collapse them — product/WebUI handlers, triggers, channels, and agent callers go through
ProductSurfaceand the capability contracts, never around them to mutate stores directly. - Session, thread, turn, and run identities are typed and must not be re-derived from display strings or transport metadata.
- LLM data is never deleted. Context, reasoning, tool calls, messages, events, steps — mark with timestamps and make filterable, but always retain. In-memory maps are caches; the database is the source of truth. "Cleanup" means evicting caches, never deleting rows.
- Never commit secrets or PII.
Capabilities, extensions, and lifecycle
- Core host behavior uses typed built-in capabilities behind the same mediated host surface as other execution.
- Sandboxed extension execution belongs in WASM or a runtime lane; external server integrations belong behind MCP and the network boundary.
- Discovery is side-effect-free. Installation, credential binding, activation, execution, deactivation, and removal are explicit lifecycle transitions.
- Capability failures the model or user can correct are model-visible outcomes; host errors are reserved for failures that end the run.
- Side-effecting success requires durable or provider-issued evidence plus read-back verification; if read-back is impossible, report explicitly unverified rather than completed.
Extension/Auth Invariants
The top-level product object is always an extension; a channel is one capability surface an extension's manifest declares (tool / channel / auth — ironclaw_extension_contracts::surface::CapabilitySurfaceKind), and runtime (wasm / mcp / first_party) is implementation, never taxonomy. ExtensionId is the product identity (slack, github, gmail); VendorId (manifest field vendor) is the credential-authority namespace and may back several extensions (google backs gmail + drive + calendar). There is no separate channel registry and no extension kind wire string — crates/app/ironclaw_architecture_tests/tests/reborn_retired_taxonomy.rs pins the retired vocabulary at zero.
Two identities must never be conflated (newtypes in crates/contracts/ironclaw_common/src/identity.rs; identity model crates/domains/ironclaw_identity/CONTRACT.md; OAuth transport crates/domains/ironclaw_auth):
credential_name— backend secret identity (storage, injection, gate resume), e.g.telegram_bot_token,google_oauth_token.extension_name— user-facing installed extension/channel identity (setup routing, UI), e.g.telegram,gmail.
Never route setup/configure UI from credential_name; chat and Settings use the same setup path; generic auth-card UI is only for non-extension credential prompts or pure OAuth launches; resolve extension_name once in shared backend logic and carry it through the wire contract instead of re-deriving it per layer or adding frontend-only fallbacks.
Adding a channel means adding one capability surface of an extension — a [channel] section in the reborn.extension_manifest.v3 manifest plus implementations of the channel traits (ChannelIngress / ChannelReply / ChannelDelivery, crates/contracts/ironclaw_extension_contracts/src/channel_adapter.rs), wired through RebornHostBindings::with_channel_extension_bindings (crates/app/ironclaw_composition/src/input.rs) — never per-channel host code. Start from the reborn-extension-surfaces skill; the worked example is crates/extensions/packages/slack/; family rules in crates/extensions/AGENTS.md.
Project structure
crates/ # all production code, by family (crates/AGENTS.md is the map)
├── app/ # ironclaw_cli (binary `ironclaw`), ironclaw_composition, ironclaw_config, ironclaw_architecture_tests
├── contracts/ # ironclaw_host_api, ironclaw_common, ironclaw_extension_contracts, ironclaw_product_contracts, …
├── domains/ # ironclaw_llm, ironclaw_skills, ironclaw_threads, ironclaw_auth, ironclaw_memory, …
├── events/ # ironclaw_event_log / _projections / _store / _streams
├── extensions/ # ironclaw_extension_host/_manager/_registry/_support + packages/ (slack, telegram, …)
├── kernel/ # ironclaw_turns, ironclaw_capabilities, ironclaw_approvals, ironclaw_host_runtime, …
├── lanes/ # ironclaw_wasm, ironclaw_sandbox, ironclaw_mcp
├── loop/ # ironclaw_agent_loop, ironclaw_turn_runner, ironclaw_loop_host, ironclaw_hooks
├── product/ # ironclaw_webui (SPA in frontend/), ironclaw_assistant, …
└── substrates/ # ironclaw_filesystem, ironclaw_safety, ironclaw_network, ironclaw_secrets, …
tests/ # root-package integration suite, parity/QA, support, e2e
The workspace root (Cargo.toml, package ironclaw_integration_tests) hosts only the integration test suite; the one workspace exclude is tools/ironclaw_silk_decoder.
docs/ is the public Mintlify site plus fenced internal material. All new
internal engineering docs (design notes, research, plans, QA maps) go under
docs/internal/ — nowhere else under docs/. A page outside the
docs/.mintignore fence is published even when omitted from docs.json
navigation (hidden pages stay reachable by URL), and .mintignore is frozen:
do not add entries. Enforced by scripts/ci/docs_publication_boundary.py
(Code Style workflow); run it to check placement.
Change discipline, and before finishing
- Keep changes scoped; preserve unrelated work in dirty worktrees; avoid generated-file churn. Security, persistence-schema, runtime, worker, CI, and secrets changes need explicit rollback/compatibility review.
- Run the narrowest meaningful checks, plus
cargo test -p ironclaw_architecture_testswhen dependency edges, layer keys, crate placement, or test-pinned guidance files change. - Search changed production files for
.unwrap()/.expect(), suspicious byte slicing, hardcoded temporary paths, and lost error causes. - When a trait changes, enumerate all implementations, decorators, adapters, and test doubles; when a pattern bug is fixed, search
crates/for sibling instances. - After moves/renames, search agent guidance, contracts, docs, tests, scripts, manifests, and frontend imports for old paths.
- Update the owning contract/docs when behavior changes; the PR title/body must describe every layer in the diff and note compatibility, rollback, and follow-up risks.