Files
ironclaw/scripts/ci/crate-dir.sh
Benjamin Kurrek d8be0c0de4 Waves 0–4 batch: WS3/WS4 consolidation + lane governor port + conversations sever + WS10 inventory keying + enforcement gates (#7170)
* refactor(contracts): move extension runtime descriptors to a neutral contract (WS3)

Deletes the two `-> ironclaw_extensions` layer-matrix exceptions
(`ironclaw_mcp`, `ironclaw_scripts`) by giving the runtimes-layer lanes a
contracts home for the descriptors they read, instead of the registry crate
they may not depend on. Exceptions 13 -> 11; baseline lowered in the same
change.

Moved to `ironclaw_extension_contracts`:
- `runtime::{ExtensionRuntime, ExtensionAssetPath, ExtensionAssetPathError}`
- `hosted_mcp::{HostedMcpDiscoveredTool, HostedMcpDiscoveredToolAnnotations}`

`ExtensionPackage`/`ExtensionManifest` deliberately stay in
`ironclaw_extensions`: they carry the whole parsed manifest tree and a
`PackageRootBinding` typed on `ironclaw_filesystem::VirtualPath`, which the
§11.2.3 contracts-purity allowlist (`{ironclaw_host_api}` only) forbids the
contracts crate from naming. Measured instead: both lanes read exactly three
things off the package — `id`, `capabilities`, `manifest.runtime` — so the
lane request structs now take those three and the caller (which owns the
package) projects them.

Also repointed `ResourceReceipt` to its real owner: `ironclaw_resources`
only re-exports `ironclaw_host_api::resource::ResourceReceipt`, so the lanes'
import was a §11.2.4 two-import-paths hop, not a dependency.

No `pub use` shims (§11.3): every consumer is repointed in this change, and
`resolve_under` becomes the free function `ironclaw_extensions::resolve_asset_under`
because the orphan rule forbids an inherent impl on the moved type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(sandbox): merge the sandbox lane into one crate (WS3)

Creates `ironclaw_sandbox` (runtimes) from the three halves of "run an
already-authorized command away from the host", and deletes the two crates
PROPOSAL §6.6.4 marks for merge:

- `ironclaw_process_sandbox` (plan contract)      -> `src/plan.rs`, `src/validation.rs`
- `ironclaw_host_runtime::sandbox_process`        -> `src/sandbox_process/**`
- `ironclaw_scripts` (script lane + Docker path)  -> `src/script.rs`

The kernel sheds the Docker/CA cone: `bollard`, `rcgen`, `x509-parser` and
`time` are gone from `ironclaw_host_runtime`'s manifest, and `bollard`/`rcgen`
are now declared by exactly one crate in the workspace.

Two migration details PROPOSAL §6.6.4 and CHECKLIST WS10 call load-bearing:
- `PROCESS_SANDBOX_CAPABILITY_ID` -> `ironclaw_host_api::capability`, so
  `ironclaw_loop_host` drops its lane dependency (production dep gone; a
  dev-dep remains for the tests that build plans).
- `SandboxCommandTransport` -> `ironclaw_host_api::process`, with the shapes
  it names (`CommandExecutionRequest`/`Output`, `RuntimeProcessError`,
  `SavedCommandOutput`, `SavedCommandOutputSanitization`). Without this the
  runtimes-layer lane could not implement what the kernel consumes.

Enumerating gates were repointed, never relaxed: the specificity carve-outs and
the struct/test-support ratchet entries moved with their files (both baselines
unchanged at 129 and their prior values), the panic-gate baseline row moved,
`reborn-crate-test-buckets.sh` registers the new crate, and the three
`reborn-e2e-rust.sh` script selectors follow the tests (plus `docker_security`,
which had no selector before).

One gate would have gone silently vacuous and was fixed rather than moved: the
script-lane surface scan in `reborn_dependency_boundaries.rs` read a hardcoded
`src/lib.rs`, which after the merge no longer holds the lane. It now scans the
whole crate source tree with a fatal-read walk and a non-vacuity assertion.

One deletion, recorded: `RebornScopedSandboxCommandTransport::into_process_port`
returned a kernel type a runtimes crate may not name. It had zero callers
workspace-wide; the kernel wraps the transport, which is the direction the port
inversion requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(target-architecture): record the WS3 corrections with their evidence

Three dated amendments, each quoting the text it replaces:

1. CHECKLIST WS3 sandbox row + PROPOSAL §6.6.4 — "all pieces currently
   unwired/test-only" is REFUTED. Three production paths cross the merged
   crate (spawn-path plan validation, the process_executor routing check, and
   the saved-command-output scope digest). The accurate claim is narrower:
   no production *execution backend*. Behavior preservation is therefore
   argued at the diff (11 of 26 moved files byte-identical, 9 more differing
   by one import line, +63/-36 overall), not inferred from deadness.

2. CHECKLIST WS3 mcp row + PROPOSAL §6.6.3 — the prior wave's "structurally
   blocked" finding is half right, and the wrong half is load-bearing: only
   `ExtensionPackage` is un-absorbable, and no lane ever needed it (both read
   `id`, `capabilities`, `manifest.runtime` and nothing else). The registry
   half of the flip is done; the `resources` half is refuted as phrased —
   the estimate/usage vocabulary the row asks about is already in
   `host_api::resource` and already imported from there, while the real
   blocker is the `ResourceGovernor` authority port and `ResourceError`'s
   denial cone.

3. Recorded as a structural finding, not a note: the sandbox row and the mcp
   row are ONE problem. `ironclaw_scripts` imports the identical DTO set, so
   the merge alone deletes zero exceptions and only the mcp carve-out lets
   either lane shed the registry edge.

Also reconciled: PROPOSAL §6.1.2's as-built inventory gains the two modules
WS3 landed (and states why `ExtensionPackage` stayed); §2's package count
66 -> 65; the §9 disposition rows for `ironclaw_scripts`/`ironclaw_process_sandbox`/
`ironclaw_mcp`; the §11.2.2 ratchet rows (13 -> 11); the WS3 verify row; the
stale WS1.3 sentence asserting the blocker as settled fact; and
`reborn_restructure_baselines.rs`'s doc table, which still read 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(sandbox): drop imports the merge left unused

`process_port.rs` no longer names `MountView` or `thiserror::Error` (both went
to `host_api::process` with the types that used them), and `sandbox_process.rs`
no longer needs `sync::Arc` after `into_process_port` was deleted. Found by
per-crate `clippy --all-targets --all-features -D warnings`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): let the Reborn PR planner plan guidance edits and crate deletions

Three fail-closed gaps in `reborn_pr_test_plan.py`, all hit by this PR and all
live on `main` today — any PR with the same change shape is unplannable.

1. `.claude/**` was unclassified, so the planner refused outright. It is agent
   guidance in exactly the sense `docs/**` is human guidance: no Rust test
   reads either as data (the only in-tree references are prose citations in
   test doc comments). Added to `IGNORED_PREFIXES`. Without this, "guidance
   travels with the change" — the restructure's own discipline — cannot be
   satisfied in a single PR.

2. `crates/AGENTS.md`, `crates/README.md`, `crates/Architecture.md` raised
   "unmapped crate path": they sit under `crates/` but belong to no package.
   Now classified as crate-tree prose, matched by "Markdown no package
   directory owns" so a genuinely unmapped crate path is unaffected.

3. An unmapped crate path used to raise. `git diff` reports a deleted crate's
   old paths and CI feeds the planner that diff, so **every crate deletion or
   rename was unplannable** — including the six deletions PROPOSAL §2 plans.
   It now widens to the exhaustive plan. This is a semantic change and it is
   the safe direction: the full plan is a superset of any narrowing, so an
   unattributable path can never cause under-selection, whereas refusing to
   plan blocks the PR instead of protecting it. Malformed input is still
   rejected by the unclassified-path branch.

Each lands with fixtures per WS10's rule, positive and negative: guidance
paths select nothing while non-guidance paths still fail closed; crate-tree
prose selects nothing while crate *code* under the same unmapped directory
widens to `full` (so the Markdown carve-out cannot swallow code). The
pre-existing `test_unmapped_crate_path_fails_fast` is renamed and rewritten to
pin the new contract rather than deleted.

Verified against this PR's real 130-path diff: the planner returns `mode:
full`, and the workflow's own exhaustiveness guard passes on that output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(arch): give the retained resource exceptions an owning issue, not a wave

Review (#7065) caught that both surviving `-> ironclaw_resources` exceptions
declared `removes_in = "WS3"` — the wave this PR *is*, which does not remove
them. That is precisely the defect §11.2.2 already records against
`conversations -> turns` ("`removes_in = "WS5"` and WS5 has partly shipped
without it falling"), and it would have been repeated here.

Both now point at issue #7067, which owns the design work that actually clears
them: replacing the `ResourceGovernor` dependency with a narrow
reserve/reconcile/release port. The issue carries the measurements — 3 of 10
methods used, zero implementors, and the `ResourceError` denial cone — plus the
two open questions (error shape, port home) that make it a design slice rather
than a move.

An owning issue is also what §11.2.2 asks for and what the ratchet still cannot
enforce (there is no `owning_issue` field yet), so this is the strongest form
currently expressible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(contracts): pin the asset-path validator that moved into extension_contracts

`validate_asset_path` moved here with `ExtensionAssetPath`, the type it
constructs. In `ironclaw_extensions` it was only ever reached indirectly
through manifest parsing, so its six rejection branches had no direct test —
and a contracts crate that carries validation owes that validation one.

Two tests: every reject branch with its exact reason and `Display` output
(empty, NUL/control, URL, absolute, Windows drive and backslash, and the
empty/`.`/`..` segment cases) plus the manifest-relative shapes that must keep
being accepted; and `ExtensionRuntime::kind()` over all five variants, since
that projection is what every lane uses to reject a runtime it does not serve.

Also removes a changed-line coverage risk this PR would otherwise carry into
the merge queue: the gate does not run on ordinary PRs (#7036), so ~100
newly-added lines of validator would first be measured where a failure is
expensive to diagnose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(coverage): re-capture the host_runtime floor and floor the new sandbox lane

`RATCHET FAIL: ironclaw_host_runtime` — observed 18854 covered vs a
`floor_covered_lines` of 20538. This is the shrinkage case the ratchet's own
"To fix" text describes, not a coverage regression: `sandbox_process/**` moved
to `ironclaw_sandbox`, so the crate's denominator fell 23277 -> 21267 (-2010
instrumented lines) and its covered lines fell with it.

The percentage floor is **raised, not lowered**: observed 88.65% against an old
floor of 88.23%, so the entry now reads 88.65. Only the absolute line count
moves down, and it must — those lines are no longer in this crate.

To keep that from being a net loss of protection, `ironclaw_sandbox` is floored
on arrival at its observed 87.09% (3185 / 3657). This is a net *increase* in
ratchet coverage: neither `ironclaw_scripts` nor `ironclaw_process_sandbox` was
ever floored, and the `sandbox_process` half was protected only as part of
host_runtime's line count, which this PR necessarily reduces. Floored crates
16 -> 17.

Verified by replaying the ratchet arithmetic against CI's observed numbers:
both crates pass on percentage and on covered lines. Numbers taken from the
failing run's own report (job 91740733521), which is the authority for this
gate.

The `Tests (Reborn)` roll-up failed solely on this sub-job
("coverage-report result 'failure' did not match planned=true"); no other lane
failed — 50 pass, 2 fail, both this root cause and its roll-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(target-architecture): record the coverage ratchet as a move-sensitive gate

WS3 hit a gate no move row had named. `tests/integration/coverage-floor.toml`
is keyed on crate identity plus absolute covered-line counts, so it is
invisible to WS10's path-keyed gate audit and yet it fails on every crate move,
merge, rename, or family `git mv` that shifts instrumented lines between
crates — as it did here, while the percentage floor was *improving*.

Recorded on WS10 with the three rules WS7 will need: re-capture in the same PR,
raise the percentage floor rather than leaving it, and floor the destination
crate or the move silently drops that code out of the ratchet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(extension-manager): repoint ironhub onto the moved ExtensionAssetPath

A semantic conflict the merge could not see: #6780 landed
`ironhub/{package,catalog}.rs` importing `ExtensionAssetPath` from
`ironclaw_extensions`, while this branch moved that type to
`ironclaw_extension_contracts::runtime`. Different files, so git auto-merged
cleanly and the breakage surfaced only at `cargo check`.

Repointed both sites to the contracts crate (no shim, per §11.3). The manifest
already named `ironclaw_extension_contracts`, so this is imports only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(coverage): exempt the WS3 move's no-region lines and record the gate

The changed-lines coverage gate went red on four files while changed-line
coverage was 95.35% against a 90% floor: the failure was its two fail-closed
STRUCTURAL assertions, not any percentage.

Every line below was derived by replaying scripts/ci/reborn_changed_coverage.py
against this PR's own merged lcov (run 30831658659) with the base lcov the gate
itself resolved (run 30828540055 @ b89fcd3575), until the replay reproduced the
CI verdict byte-identically. Line numbers come from the gate's own
`candidate_lines - mechanically_uninstrumentable_lines()`, not from the log.

- host_api/src/process.rs (31 lines): new placement-neutral process vocabulary
  with no function body anywhere in the file; rustc emits no LCOV record for it
  at all. Same shape already exempted for product_contracts/loop_contracts.
- extension_contracts/src/hosted_mcp.rs (12): field declarations of the two new
  tools/list descriptor structs. The file is plainly instrumented (191 DA, 164
  hit), so this is a no-region artifact, not an instrumentation gap.
- host_runtime/src/services/runtime_adapters.rs (13): continuation lines of
  three rewritten calls, all PROVEN EXECUTING by their region-start heads
  (lines 380/434/977 score 24/16/63 hits). The four genuinely-uncovered lines
  in the same rewrite are deliberately NOT exempted -- the gate already
  subtracts them as pre-existing debt inherited from base.
- composition capability_host_tests/approval_gates.rs (6): type positions in a
  test double whose body region scores 1 hit.

The last one is a finding, not just a waiver: that file is 100% test code
behind `#[cfg(test)] mod capability_host_tests;`, but the gate's
test_only_path() recognises /tests/, /test_support/, */tests.rs and *_tests.rs
and NOT a cfg(test) module DIRECTORY, so it measures it as production. It is
the only such directory in crates/ today.

Docs (target-architecture, same PR per the docs-truth rule):
- CHECKLIST WS10 gains the changed-lines gate beside the ratchet row, cross-
  referencing the WS2.1 note rather than restating it: percentages are not what
  fail a move; derive lines by byte-identical replay (--fetch-base-coverage
  silently degrades without --github-repo); and a stranded exemption path is an
  ABORT with no verdict, not a loud failure.
- CHECKLIST WS10 exception-ratchet row: the constant was cited at :4063 and
  sits at :4164 -- corrected by removing the line pin, since the file is edited
  every wave. Records that the baseline is a UNION across parallel WS3 lanes.
- families/contracts.md: records extension_contracts' new ownership of the
  runtime descriptor vocabulary -- the carve-out that let BOTH lanes drop the
  registry edge -- and the orphan-rule seam that keeps resolve_asset_under in
  the registry crate.
- families/lanes.md: two "Never" claims were reading as satisfied when they are
  not. ironclaw_mcp's "never depends on the resource-governor crate directly"
  is refuted (the compiled edge survives; #7067 tracks the narrow port), and
  ironclaw_sandbox's "no direct process spawning outside the transport seam" is
  aspirational -- script.rs:454 still builds Command::new("docker").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(sandbox,mcp): correct the wiring inventory and record the projection cost

Two review findings verified against the tree; three refuted with evidence in
the PR threads.

Valid — the sandbox wiring inventory was self-contradictory. `CLAUDE.md` said
"Two production call paths ... and both are plan validation" directly above a
list of THREE bullets, and `lib.rs` omitted the third entirely. The third is
real and is not validation: `host_runtime/src/process_output.rs:482` derives the
scoped saved-output directory through `RebornSandboxScopeKey::from_scope`. That
inventory is what tells a future agent which paths are live, so an undercount
invites deleting a production path as dead code. Both surfaces now say three and
no longer claim they are all plan validation (the `loop_host` capability-id
comparison never was either).

Valid, and recorded rather than redesigned — the registry carve-out cost a
type-level invariant. Replacing `package: &ExtensionPackage` with independent
`extension` / `capabilities` / `runtime` borrows is what deleted the
`mcp -> extensions` and `scripts -> extensions` exceptions, but it also means
the type no longer guarantees the three came from one package.
`execute_extension_json` re-checks the descriptor half
(`descriptor.provider == extension`); the runtime half cannot be re-derived,
because nothing in an `&ExtensionRuntime` names its owning extension. No caller
can trip it today -- there is exactly one production caller
(`runtime_adapters`) and it projects all three from one package in one
expression -- so this is a latent structural weakening, not a live defect.
Restoring the compile-time binding needs a sealed projection minted by the
package owner; a check inside the lane cannot express it, and re-taking the
registry edge would undo the carve-out. Both request types now carry the caller
obligation in their field docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(extensions): move the skill-install executor to extension_support (WS3)

WS3's first-party-tools row, family 1 of 6: skill management / URL install.

`skill_url_install.rs` and its `bundle`/`github`/`zip_bundle` submodules,
plus the install-input normalizer, move out of
`ironclaw_host_runtime::first_party_tools` into
`ironclaw_extension_support::skills::{url_install, resolve_install_input}`,
where the skill executor half already lived. Move-only: no behavior change,
no test edited for content.

`ironclaw_host_runtime -> ironclaw_skills` is deleted from
LAYER_MATRIX_EXCEPTIONS — the edge is gone, not waived (exceptions 13 -> 12,
WS0_LAYER_MATRIX_EXCEPTION_BASELINE drops with it). `ironclaw_skills` and
`zip` survive as dev-dependencies for host_runtime's own tests; dev edges are
outside the matrix by construction.

Two doc ambiguities are resolved in the same diff, as dated PROPOSAL
amendments quoting the text they replace:

- §6.8.4's "the builtin first-party tool handlers absorbed from
  host_runtime/first_party_tools" contradicted §8.2's "kernel: ✗ (ports only)"
  row and the enforced BoundaryRule. Resolution: the seam splits executor from
  adapter — the executor moves behind a neutral request/error pair, the
  FirstPartyCapabilityHandler / CapabilityManifest / registry wiring stay
  host-side. Same shape the groupware and web-access tools already ship.
- §8.2's "ports only" cell now says what it means: contracts-layer ports the
  kernel also consumes, not permission to name a kernel trait.

Two cost corrections recorded for the remaining families:
`host_runtime -> extension_support` is not divisible family-by-family (mod.rs
holds it via `extension_support::coding`), and
`host_runtime -> ironclaw_extensions` is not reachable by this row at all.

PATH_TERM_COLLISIONS shrinks by two: the installer's github carve-outs now sit
inside a scan-exempt crate.

Test accounting (un-masking discipline), unfiltered `--list` over both crates:
1398 -> 1398, with exactly two tests renamed by module path and none lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(sandbox): record that the Docker fail-closed switch is wired to nothing

Review asked why the migrated docker_security test can pass with no daemon.
The skip is pre-existing (the file differs from its pre-merge original by one
import line); WS3 only enrolled it in the required Rust e2e lane, where it was
not run at all before.

The real defect the question surfaced is worse and also pre-existing: this
crate's tests/support/docker_gate.rs states that IRONCLAW_REQUIRE_DOCKER_TESTS=1
makes a missing daemon a hard failure and that "CI sets this" -- and nothing
sets it. Repo-wide the name occurs only in docker_gate.rs and
attribution_tests.rs, here and on main. So every real-Docker test in the crate
skips-and-passes everywhere, which is exactly the gap the gate's own comment
says let sandbox security bugs ship unnoticed. docker_security.rs additionally
open-codes its own check rather than using the gate, so it would stay fail-open
even once something did set the variable.

Recorded rather than fixed: setting the variable is a CI-behavior change that
would hard-fail any lane without a daemon or the ironclaw-worker image, which
is not verifiable from inside a move PR whose evidence claim is behavior
preservation. Filed as the #6945 guardrail-claim-vs-reality class with the
two-part fix stated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(host_runtime): record the executor/adapter seam in crate guidance

The crate's CLAUDE.md said "first-party runtime tools belong under
`first_party_tools/`" without saying that only the host half does. WS3 moves
each tool's executor into `ironclaw_extension_support`, which may not name this
crate, so the rule now names both halves and points at the skill-install family
as the worked example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(host_runtime): keep the install-input error path log-free

The moved executor returns `SkillManagementCapabilityError`, and routing it
through `skill_management_error` would have added a `debug!` line to a path
that had none before the move. A move-only change must not add one, so the
install-input arm maps the kind directly and the `dispatch` arm keeps the
record it already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(coverage): re-capture the host_runtime floor for the WS3 executor move

The ratchet does not run on `pull_request` (`reborn_pr_test_plan.py:21`; issue
#7036), so this PR's green checks were not evidence on this axis. A full-plan
`workflow_dispatch` run on this exact head reported:

  RATCHET FAIL: ironclaw_host_runtime
    observed: 88.59% (20485 / 23124 lines)
    floor:    88.23% ... floor_covered_lines: 20538 (effective floor 20518)

The percentage went UP while `floor_covered_lines` went DOWN — shedding
well-covered code lowers the absolute numerator, which is a separate assertion
from the percentage one. Re-captured to the observed numbers (floor raised
88.23 -> 88.59, not merely held). Verified locally against that run's own merged
lcov artifact: ENFORCING mode, 17 PASS / 0 FAIL, exit 0.

  run: https://github.com/nearai/ironclaw/actions/runs/30858257594
  head: e07b3b0299

The destination crate is deliberately not floored, because it cannot be: every
crate under `crates/extensions/` is invisible to the coverage tooling —
`reborn_coverage_lcov.py:19`'s CRATE_RE still requires a crate directory
directly under `crates/`, which #7037's colocation broke. Filed as #7083 with
the measurement; the global floor is left alone rather than re-captured onto
that hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(wasm): move wit/ inside its owning crate (Wave 3)

CHECKLIST WS4 + WS10 `wit/` rows. `wit/{tool,channel}.wit` moves from the
repo root to `crates/ironclaw_wasm/wit/` — the crate that owns the ABI —
per PROPOSAL §6.6.1. Behavior-free: same bytes, same generated bindings.

Wave-3 coordinates: the docs write the destination as
`crates/lanes/ironclaw_wasm/wit/`, but `crates/lanes/` does not exist until
WS7. Because the files now sit *inside* the crate, the WS7 family move
carries them with no further path edit anywhere — which is the whole point
of putting them there.

Ten wit-bindgen `path:` args repointed (the host plus nine guests: six under
`crates/extensions/packages/*/wasm-src/`, three under `test-tools/*/wasm-src/`
— the CHECKLIST row said six). All nine guests verified building against the
moved WIT on wasm32-wasip2.

The four `include_str!` readers of the ABI text do NOT get repointed
literals. Doing that would turn the two `ironclaw_host_runtime` sites from
repo-root reach-ins into *cross-crate* ones — §11.2.7's strict class, the
one WS2 turns into hard failures — taking the scan from 19 to 21 while
ticking a box that says "§11.2.7 scan passes". Instead the ABI text gets one
owner, `ironclaw_wasm::TOOL_WIT` (`src/config.rs`, beside `WIT_TOOL_VERSION`),
and all four sites read the const over cargo edges that already exist.
Measured with the scan: 133 -> 129 escaping sites, cross-crate 19 -> 19,
zero `wit/` entries remaining.

Path-keyed gates repointed: `scripts/check-version-bumps.sh` (both ABI
paths), `.githooks/pre-commit`, and `platform-and-compat.yml`'s
`has_direct_wasm_abi_risk` filter — where the bare `wit/` alternative is
*deleted* rather than rewritten, because the filter's existing
`crates/([^/]+/)*ironclaw_wasm/` alternative already matches both the
Wave-3 and the WS7 location. `scripts/ci/ws12_workflow_contracts.py`
anchored on that deleted string, so its anchor moves to
`build-wasm-extensions` and its in-scope probe now pins both locations.

`Dockerfile` loses two `COPY wit/ wit/` lines in the planner and builder
stages: both already run `COPY crates/ crates/`, so the files arrive with
the crate and the old line would COPY a path that no longer exists.

Docs: the WS4 row's `crates/lanes/wit/` destination was the only doc site
placing the directory beside the crate rather than inside it; corrected
there and in README's tree, with dated amendments in CHECKLIST, PROPOSAL
§6.6.1 and PLAN Wave 3 recording what the move found.

Test accounting (unfiltered `--list`, name-by-name, quiescent tree):
ironclaw_wasm 51 -> 51, ironclaw_host_runtime 1246 -> 1246,
ironclaw_architecture 198 -> 198. Zero diff, no test edited for content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build(wasm): rebuild first-party artifacts for the moved wit/ path

Forced by the previous commit, not incidental to it.
`scripts/ci/check-wasm-artifact-freshness.py` keys each package's committed
`wasm/<name>.wasm` to a digest of the `wasm-src/` tree that produced it, so
editing a guest's `wit_bindgen::generate!` `path:` — which the `wit/` move
requires in all six shipped guests — invalidates the recorded digest and
fails the gate.

The gate's own contract forbids the shortcut: "Re-record only after
`./scripts/build-wasm-extensions.sh --first-party` and committing the rebuilt
artifact — the digest asserts a claim about the artifact, and updating it
without rebuilding launders a stale one." So the artifacts are genuinely
rebuilt (`--first-party`, exit 0, 6 OK / 2 host-native SKIP), not re-recorded
in place.

Byte sizes move by more than the source change accounts for because these
builds are not reproducible by design — the guests pin no toolchain and
resolve their own `Cargo.lock` at build time, which is the documented reason
the gate hashes sources rather than artifact bytes.

Verified: `check-wasm-artifact-freshness.py` OK (6 packages), and
`cargo test -p ironclaw_extension_support` green (102/46/4) — that crate
`include_bytes!`s these artifacts, so it exercises the rebuilt components.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(target-arch): record the WS7 artifact-rebuild cost of guest path edits

The `wit/` move had to rebuild six shipped WASM binaries because
`check-wasm-artifact-freshness.py` digests each guest's whole `wasm-src/`
tree. WS7 hits the same wall from the other direction: the six package
guests reach the ABI across two trees, so moving either `ironclaw_wasm` or
`extensions/packages` rewrites all six `path:` literals and forces the same
rebuild. Recorded on CHECKLIST WS10's `wit/` row (point 6), on the
loud-path-pattern row that owns the WS7 repoint (also corrected six -> nine
guests there), and on PLAN's Wave 5 block with the cheap mitigation: move
the two crates in one PR and pay it once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(planner): classify the path classes that blocked the wit/ move

`Detect Reborn test scope` exits 1 on any pull request whose diff holds a
path `reborn_pr_test_plan.py` has no rule for, which made this PR
unmergeable: it must edit `Dockerfile` (the moved directory's
`COPY wit/ wit/` no longer resolves) and `scripts/check-version-bumps.sh`
(the ABI gate would otherwise grep dead paths and silently stop
enforcing). 18 of its 46 paths were unclassified.

Same class as the `.claude/` gap #7064 fixed, and classified the same
way — one rule per class, recorded beside the constant:

  * `Dockerfile` / `.dockerignore` — `platform-and-compat.yml` keys
    `has_docker_risk` off exactly this pair and owns the image build.
  * `.githooks/**` — Code Style triggers on the tree and lints its
    contents (`test-ci-comm-locale-pin.sh`); no Reborn lane runs a hook.
  * `scripts/{build-wasm-extensions,check-version-bumps}.sh` —
    `platform-and-compat.yml`'s `has_direct_wasm_abi_risk` classifier
    both scopes and runs them.
  * markdown owned by no crate (`crates/AGENTS.md`,
    `test-tools/README.md`) — prose, like `docs/` and `.claude/`. A
    crate-resident doc still selects its own crate's lane.

The first-party extension package assets are deliberately NOT ignored.
`crates/extensions/packages/*/wasm/*.wasm` is a shipped artifact that
`ironclaw_extension_support` embeds with `include_bytes!`, and
`test-tools/*/manifest.toml` is `include_str!`d by
`ironclaw_extension_host`. Calling either prose would convert today's
loud failure into a silent under-schedule of a change to production
output — the WS10 failure mode. `EMBEDDED_ASSET_OWNERS` routes each tree
to the crate that compiles it instead, so this PR now additionally
schedules `ironclaw_extension_{support,host,manager}`: the crates that
consume the six rebuilt WASM artifacts.

Also fixes #7085 in a file this PR already touches. The WIT version
extractors used the GNU-only BRE `\+`, so on BSD sed (macOS) they matched
nothing, and because the `WIT_TOOL_VERSION` cross-check is guarded on a
non-empty version the hook printed "All version checks passed" having
compared nothing. `[[:space:]][[:space:]]*` is identical under GNU sed,
so the enforced Linux CI lane is unchanged; verified on BSD sed that both
`wit/tool.wit` (0.3.0) and `wit/channel.wit` (0.3.1) now extract.

Regression tests: every classified class gets a case in
`test_reborn_pr_test_plan.py`, including the paired assertion that the
embedded assets *select a lane* rather than merely being accepted (the
inverse of the `.claude/` prose test), and a staleness pin that fails if
an asset tree or its owning crate moves. All ten new cases fail against
the planner on `main`. `test_unclassified_build_input_fails_fast` moves
off `Dockerfile` onto a still-undecided input so the fail-closed arm
stays exercised.

Refs #7087, #7085

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(host-runtime): split obligations into its three chartered owners (WS3)

`crates/ironclaw_host_runtime/src/obligations.rs` was 3,122 lines fusing the
three owners PROPOSAL §6.5.9 charters separately, held apart only by an
`// arch-exempt: large_file` waiver. It is now one module per owner:

- `obligations::handler` — which obligations apply and what each does
  before/after dispatch, plus the audit/redaction/ceiling/mount validation.
- `obligations::staged_handoffs` — material staged for a later consumer:
  the runtime-secret and network-policy stores and the credential-account
  resolver port.
- `obligations::process_store` — post-start handoff discard and reservation
  reconciliation.
- `obligations::mod` — only `BuiltinObligationServices`, the assembly seam,
  and deliberately the one place naming all three at once.

Every module is under the 1,500-line gate, so the waiver is deleted rather
than carried forward: re-fusing the owners now trips `pre-commit-safety.sh`.
`mod obligations;` stays private and the crate's `pub use obligations::{…}`
names are unchanged, so no consumer outside the crate sees this.

Behavior-free. Cross-owner access is `pub(super)` (three methods), not
`pub(crate)`. The split revealed one narrowing in the other direction:
`secret_present` was `pub(crate)` with no caller outside its own file and is
now private.

Also from the same CHECKLIST row, the bounded half of "shrink
`services/builder.rs` toward composition-facing factories": three builder
methods whose only callers are inside the crate's `src` narrow to
`pub(crate)`. The rest of that clause is measured and deferred in the
CHECKLIST amendment — 17 methods need a `test-support` cargo feature, three
are callerless and belong to WS8, and the remaining 33 are a redesign of the
fluent surface rather than a shrink of it. `+production_wiring` is refuted
there: it is readiness diagnostics, not assembly.

Two loud path-keyed gates fired and were repointed, not relaxed:
`reborn_host_runtime_services_do_not_expose_lower_substrate_handles` now
scans the whole `obligations/` directory and asserts it read ≥ 4 files
(`collect_runtime_rs` returns a count; both its callers now assert non-zero),
and `reborn_struct_test_support_ratchet`'s frozen per-file count moves to
`staged_handoffs.rs` with its count unchanged at 1.

Test accounting (un-masking discipline): `cargo test -p ironclaw_host_runtime
--all-targets -- --list` is 1,246 before and 1,246 after, name-by-name
identical — zero added, removed or renamed. `LAYER_MATRIX_EXCEPTIONS` is 10
before and after; an intra-crate split cannot move the register.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(operator,contracts): route operator secrets through a product_contracts port (WS3)

`ironclaw_operator` is a products-tier crate and held `ironclaw_secrets`, the
substrate that owns CAS one-shot leases, AAD/crypto and the OS keychain master
key. PROPOSAL §8.2's product row says the products tier loses that edge, and
§12.1b requires the port replacement to land before the edge is removed. Both
happen here, in that order.

- Port: `ironclaw_product_contracts::operator_secrets::OperatorSecretValueStore`.
- Implementor: `ironclaw_reborn_composition::RuntimeOperatorSecretValueStore`,
  the same placement as `OperatorStatusService` — assembly is the only layer
  that may name both a products-tier port and a substrate. Registered in
  `INVERTED_PORTS` beside it.
- `ironclaw_secrets` is gone from the operator manifest under every dependency
  kind, and `"ironclaw_secrets"` is now in the crate's `boundary_rules()`
  forbidden list. That gate's comment previously said the entry was
  deliberately absent because "the row owns it"; the row now owns it.

The port is deliberately narrower than the substrate, so this is a tightening
rather than a relocation: it takes no `ResourceScope` (the implementor fixes
the operator scope, where the caller used to pass one), exposes no
lease/consume protocol, and carries only a `&'static str` classification
instead of the substrate's error `Display` — asserted, including that the
backend message and the handle name are both absent from what crosses.

Two tests travelled with the behavior rather than being pointed at a fake:
`read_is_repeatable_across_reloads` (repeatability is a property of the lease
protocol) and the #4673 production-store reproduction (its value is wiring the
store exactly as production does, which now means the real store *behind the
adapter*). Two `FaultInjecting`-over-real-store fixtures became per-operation
port fakes, with the substrate error mapping re-pinned at the adapter; a third
assertion got stronger — batched-vs-N+1 stored-key lookup is now observed at
the port rather than by counting filesystem ops.

Test accounting: operator 154 -> 153, product_contracts 142 -> 143,
composition 937 -> 942 with zero removed; name-by-name diffs on a quiescent
tree.

Two findings the row could not have anticipated, both recorded in the
CHECKLIST amendment:

- The `webui` half of the row was already closed and was never a production
  edge. `ironclaw_secrets` has been a dev-dependency of `ironclaw_webui` since
  the commit that added it (#6619), both src mentions are `#[cfg(test)]`, and
  webui's boundary rule already forbade it.
- `ironclaw_extension_manager` (layer `products`) still holds a normal
  `ironclaw_secrets` edge in `admin_configuration.rs`. §8.2 covers it; the row
  does not, because the crate landed with WS2.4 after the row was written, and
  the substrate sits in the service's type parameters so it is not a
  like-for-like swap. Filed as #7095.

`LAYER_MATRIX_EXCEPTIONS` is 10 before and after: `products -> substrates` is
matrix-legal, so this edge was always an §8.2 rule and never a layer exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(sandbox): put the Docker security check behind the fail-closed gate

Review asked why the required Rust e2e lane can report `docker_security` as
passing with no daemon. Half of that is #7081 (nothing sets
IRONCLAW_REQUIRE_DOCKER_TESTS=1, so the switch is inert) and is not fixable
from here -- arming it hard-fails any lane lacking a daemon or the worker
image, which needs a runner guaranteed to have both.

The other half is fixable here and is fixed: docker_security.rs open-coded its
own `docker version` / `image inspect` checks with three bare `return`s, so it
sat entirely outside docker_gate and would have stayed fail-open even once
something did set the variable. It now takes both preconditions from
docker_gate::{docker_available, docker_image_available} and skips with the
visible `SKIP:` line that gate's module doc requires.

Measured, same machine, image absent:

  before, IRONCLAW_REQUIRE_DOCKER_TESTS=1 -> "skipping ..." / 1 passed
  after,  IRONCLAW_REQUIRE_DOCKER_TESTS=1 -> panic at docker_gate.rs:74 / FAILED
  after,  variable unset                  -> "SKIP: ..." / 1 passed

The third line is the no-op proof: the variable is set nowhere in this tree or
on main, so no lane's behavior changes today. The daemon-down path already
reached the image check and skipped there, so the outcome is identical; only
the branch it takes differs.

Two stale comments in docker_gate.rs corrected with it (they claimed
docker_security used its own gate, and that docker_image_available had no
consumer), and the crate's Known debt entry now splits the done half from the
#7081 half instead of describing both as open.

cargo test -p ironclaw_sandbox: 193 passed, 0 failed
cargo clippy -p ironclaw_sandbox --tests --all-features -- -D warnings: exit 0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(reborn): stop calling the unwired script lane an execution lane

Two review findings, both correct, both artifacts of this PR's own renames.

1. engine-v2-to-reborn-parity.md note 4 read "a native script/software
   execution lane (`ironclaw_sandbox`, `RuntimeKind::Script`) sandboxed via
   `ironclaw_sandbox`" -- self-referential after the merge collapsed
   ironclaw_scripts and ironclaw_process_sandbox into one crate, and it
   contradicts note 5 four paragraphs down ("no production execution backend
   is wired for it"). Re-stated as the typed runtime contract it is, citing
   the measurement: `with_script_runtime` has zero production callers
   (`rg` finds only the builder itself, docs, and 30 test call sites).

2. CHECKLIST WS10 ratchet note 2 said "raise the percentage floor ...; only
   the line count should fall". That generalises WS3's sandbox merge, where
   observed coverage happened to rise. It is wrong as guidance for WS7, and
   the counterexample is in this same file: the 2026-08-03 entry from #7064
   records ironclaw_runner falling 85.55% -> 82.53% because the shed removed
   the crate's better-covered half, holding the floor, and RATCHET FAILing in
   the merge queue. Note 2 now says re-capture from the merged artifact, and
   lower only with that entry's move-not-regression counterfactual (add the
   moved files back, confirm the union clears the old floor, plus a zero-tests-
   lost name set-diff).

cargo test -p ironclaw_architecture: 32 targets, 206 passed, 0 failed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): pin the WIT scope probes and the embedded-asset owner pairing

Three review findings on the `wit/` move, each verified before it was acted on.

1. `ws12_workflow_contracts.py` probed `crates/ironclaw_wasm/wit/host.wit` and
   its nested twin. No `host.wit` exists in this repository — `git ls-files
   '*.wit'` returns only `tool.wit` and `channel.wit` — so both probes sat
   under the `crates/([^/]+/)*ironclaw_wasm/` alternative and re-asserted the
   crate-name term while saying nothing about the canonical ABI contracts. In
   a validator whose stated design is "probe derived from reality rather than
   from a guessed layout", a fabricated filename is a defect on its own terms.
   Replaced with a `crate_globs` entry, `("ironclaw_wasm", "wit/*.wit")`, which
   discovers the contracts on disk, requires each in scope, and synthesises the
   nested WS7 form — so a third contract, or the directory leaving the crate,
   fails the pin instead of passing on a stale name. Verified non-vacuous:
   narrowing the workflow alternative to `.../ironclaw_wasm/src/` now reports
   `tool.wit`, `channel.wit` and the nested probe as out of scope.

2. The embedded-asset routing test substituted `alpha`/`beta` owners so it
   could reuse the synthetic workspace. That exercised the real prefix strings
   through the real routing, but left the prefix->owner *pairing* — the table's
   entire semantic content — asserted nowhere: swapping
   `ironclaw_extension_support` and `ironclaw_extension_host` passed. Fixed in
   two halves. The routing test now drives the real `EMBEDDED_ASSET_OWNERS`
   against a workspace carrying the real owners' names and real manifest paths
   (the synthetic one could not: `build_plan` rejects a changed package outside
   the canonical set), asserting the real owner is selected. And the not-stale
   test now derives the same pairing from the tree instead of restating the
   constant: it resolves every literal `include_str!`/`include_bytes!` in every
   workspace crate through `crate_tree`, keeps the targets no crate owns — the
   ones that actually reach the table — and asserts that every crate compiling
   one of them is the routed owner or a dependent of it.

   That surfaced a property worth pinning: `crates/extensions/packages/` is
   embedded by four crates, not one. `ironclaw_extension_host`,
   `ironclaw_extension_manager` and `ironclaw_reborn_composition` reach into it
   alongside `ironclaw_extension_support`, and routing to the support crate
   covers them only because each depends on it. If that edge goes, a shipped
   artifact change stops scheduling a crate that embeds it — the silent
   under-schedule the table exists to prevent.

   Regression coverage verified red by sabotage, all three wrong tables:
   owners swapped (7 failures), `packages/` -> `ironclaw_llm` ("embeds nothing
   from it"), and the hardest case, `packages/` -> `ironclaw_reborn_composition`
   — a real embedder that the other embedders do not depend on
   ("...does not depend on..., so routing there never schedules it").

3. CHECKLIST WS10 claimed each of the nine `wit_bindgen` guest edits forces a
   committed WASM artifact rebuild. Only six do:
   `scripts/ci/check-wasm-artifact-freshness.py` scans
   `crates/extensions/packages/*/wasm-src` alone, `wasm-src-digests.toml` holds
   exactly six entries, and `git ls-files '*.wasm'` returns exactly those six.
   The three `test-tools/*/wasm-src/` guests commit no artifact; the tenth site
   is the host's `bindings.rs`, not a guest. Corrected, and the `wit/` row now
   states the boundary rather than implying it.

Guest paths, `wit/` contents and the six rebuilt artifacts are untouched.

Verified: `test_reborn_pr_test_plan.py` 46/46, `test_ws12_workflow_contracts.py`
25/25, `ws12_workflow_contracts.py` green on the real tree,
`cargo test -p ironclaw_architecture` 206/206 across 32 binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(host-runtime): state the obligation visibility rule as it holds

Review catch (#7090): the guardrail sentence promised "cross-owner access is
`pub(super)`, never `pub(crate)`", which is stronger than the code. Verified:
`RuntimeSecretInjectionStore::{insert, take, clone_material,
discard_for_capability}`, `NetworkObligationPolicyStore::{insert, get, take,
discard_for_capability}` and both constructors are `pub(crate)` and must stay
so — `src/egress/{mod,host_port,credential}.rs` call them, and that is
host-runtime composition outside `obligations/`.

The rule is restated as the property that actually holds: a method whose only
callers are inside `obligations/` is `pub(super)` (the three that are), and
`pub(crate)` is what the stores expose to the egress pipeline they exist to
serve. A future agent reading the old sentence would have read the existing
`pub(crate)` methods as violations.

Guidance-only; no code change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(architecture): put the operator secrets boundary entry on the right rule

Review catch (#7096), and it is the serious kind: the `"ironclaw_secrets"`
entry landed in `ironclaw_extension_contracts`'s forbidden vector, not
`ironclaw_operator`'s. The suite still passed, because `extension_contracts`
has no such dependency and `ironclaw_operator` then had no entry at all — so
the guard this row exists to add was inert, and a green architecture suite was
evidence of nothing. Reintroducing the edge would have passed every check.

Moved to `ironclaw_operator`'s vector; `extension_contracts` restored to its
`origin/main` content byte-for-byte.

Negative-probed rather than assumed. With `ironclaw_secrets` temporarily
re-added to `crates/ironclaw_operator/Cargo.toml`:

    reborn_crate_dependency_boundaries_hold ... FAILED
    ironclaw_operator must not have a normal dependency on ironclaw_secrets

and with the manifest restored, 35/35 pass.

Two further review findings, both verified before being accepted:

- `ironclaw_extension_manager` **does** have a `boundary_rules()` entry
  (`:3543-3556`, added with WS2.4). The CHECKLIST residue note and PROPOSAL
  §8.2's 2026-08-02 amendment both said it had none; §8.2's sentence is stale
  and is marked superseded. The real gap is narrower and now stated: the rule
  exists and simply does not forbid `ironclaw_secrets` (#7095).
- `ironclaw_product_contracts`'s guide claimed "twenty-four shipped modules".
  Measured: `src/lib.rs` has 26 shipped (27 `pub mod` less the gated
  `test_support`), and the table was missing `ironhub` **before** this branch
  touched it. Count corrected to twenty-six and the missing `ironhub` row
  added, so the inventory matches `lib.rs`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(sandbox): state the Docker-gate claim as the search that checks it

Review caught a false inventory in the Known debt entry, and the previous
commit is what made it false: "the name appears only in docker_gate.rs and
attribution_tests.rs" stopped holding the moment docker_security.rs gained a
module doc naming the variable, and CLAUDE.md itself was already a third
counterexample.

The narrower claim is the one that was always meant and is the one that
matters, so it now carries its own reproduction: no workflow, script, env file
or manifest mentions the name at all -- `git grep` over *.yml/*.yaml/*.sh/
*.toml/*.py/*.json/.env* is empty here and on main -- and the sole code
reference is a read, std::env::var(...) at docker_gate.rs:23. Every other
occurrence is a doc comment or a panic message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(coverage): re-anchor the exemptions the merge shifted

tests/integration/changed-coverage-exemptions.toml is exact-line-keyed and
auto-merges silently. #7096's additions to ironclaw_reborn_composition moved
four entries' subject lines by +2 without anything flagging it; a stranded
entry makes the changed-coverage validator abort with no verdict at all.

Re-anchored by content (difflib line map from the #7065 tree, which the file
was validated against, to the union) rather than by arithmetic:
  runtime.rs [4068..4073, 4082, 4083] -> [4070..4075, 4084, 4085]
  runtime.rs [3701] -> [3703] ; runtime.rs [3433] -> [3435]
  lib.rs     [616]  -> [618]
All 142 entries / 1124 line references re-verified against the merged tree:
0 drift, 0 out-of-bounds, 0 missing paths.

* refactor(layers): re-layer processes -> kernel and skills -> substrates (WS3/WS4)

Two CHECKLIST rows, both of which were a one-line manifest correction rather
than a code move: the family docs already placed both crates where the rows
want them and only `Cargo.toml`'s `layer =` disagreed.

processes -> kernel (WS3). families/kernel.md already lists ironclaw_processes
among the kernel crates. The re-layer makes processes -> resources a
kernel -> kernel edge, so its LAYER_MATRIX_EXCEPTION went STALE and the gate
said so itself:

  Stale IronClaw crate layer matrix exceptions:
  ironclaw_processes -> ironclaw_resources from 2026-07-09 should be removed
  in W7: runtime process management still depends on resource contracts
  currently classed with kernel behavior

That is the gate's verdict, not a judgement call - deleting the entry is the
only way to make it pass. Baseline 5 -> 4, recomputed as len(merged list).
Checked the direction both ways: all nine crates that take a normal dependency
on processes (capabilities, turns, host_runtime, extension_host, loop_host,
extension_manager, runner, reborn_composition, stress) are kernel or above, so
the move legalizes an edge without forbidding an existing one.

skills -> substrates (WS4 SS3.D). families/domains.md already lists
ironclaw_skills under 'Layer(s): substrates'. Its only two normal dependencies
are ironclaw_filesystem (substrates) and ironclaw_host_api (contracts), both
at or below substrates, and its six consumers are all loops or above. No
exception moves in either direction.

cargo test -p ironclaw_architecture: 206 passed, 0 failed.
cargo check --workspace --all-targets: clean.

* docs(target-arch): close the WS3/WS4 rows this work satisfies, with evidence

Every tick was verified against the merged tree, never against a PR title.

TICKED:
- sandbox lane merge: ironclaw_sandbox exists, ironclaw_scripts and
  ironclaw_process_sandbox absent, bollard/rcgen declared by exactly one
  manifest in the workspace.
- mcp drops the registry dep: ironclaw_extensions is [dev-dependencies] only,
  0 production ironclaw_extensions:: refs in src/.
- skills -> substrates: landed here.
- hooks libSQL/Postgres [decision]: ADR recorded - keep both, with the four
  rejected alternatives and the evidence they are already converged on one
  trait plus a shared conformance suite. #6945 read first as the row demands,
  and explicitly NOT discharged: this PR changes nothing in the dispatch path.
- WS3 verify row: the row conflated Wave 3 with Wave 5 work (9 of its 10
  exceptions carried removes_in = W7). Corrected with the replaced text
  quoted, the Wave-3 half satisfied edge by edge, and the Wave-5 remainder
  named with its owning field value. Ticked on the corrected condition.

LEFT OPEN OR PARTIAL, each with measurements rather than a hand-wave:
- first_party_tools: 1 of 6 families moved; 15 modules still in host_runtime.
  Ticking would be false.
- processes/capabilities row: re-layer DONE; the capabilities/host.rs split is
  deferred with every module boundary already computed (4,560 lines, the six
  workflow ranges, and the arch-exempt waiver that must be deleted with it).
- host_runtime binding/catalog-defaults: binding half REFUTED (moving it needs
  RuntimeLaneExecutor/RuntimeLaneRequest made pub, contradicting the same
  section's Keeps clause; zero external references to either). Catalog half
  cannot go to extension_host at all - host_runtime is itself a production
  consumer at memory_native_extension.rs:96,101, so the move is a
  kernel -> products edge and a Cargo cycle. Correct destination is downward.
- network test_rewrite: NOT executed. Recorded the security shape (production
  binaries compile the seam and honour the rewrite env var at runtime) and the
  full 6-step plan, because the env var is how the entire E2E suite redirects
  vendor traffic through the production binary and the change needs feature
  forwarding into CI lanes I cannot verify here.

cargo test -p ironclaw_architecture: 206 passed, 0 failed.

* ci(coverage): recapture the two composed floors from a real measurement

The provisional values were arithmetic - the sum of the two slices' recorded
deltas - and the dispatch caught them, which is the whole reason the brief
demanded a measurement rather than a reconciliation.

Dispatch run 30907774036 at 4512e03e28:
26 success / 1 skipped / 2 failure, judged by per-job tally per #6978. The one
skip is the pull_request-gated mutation gate; the two failures are the coverage
report and the roll-up it drags down, i.e. this file doing its job.

ironclaw_host_runtime: predicted 89.05% (18801 / 21114), MEASURED 88.63%
(17562 / 19814). The composition was wrong by 1300 denominator lines because
both slices measured their delta under the pre-#7083 aggregator, which could
not see crates/extensions/** at all - lines leaving host_runtime for
extension_support vanished from the tree it could measure, so neither branch's
recorded delta describes the post-#7094 world.

ironclaw_extension_support: MEASURED 75.31% (7142 / 9484) against #7094's
82.64% (6826 / 8260), captured before #7080's executor lines arrived.
floor_percent FALLS 7.33pp and that is flagged in the file for an owner's eye
rather than written quietly. Evidence it is composition and not lost tests:
floor_covered_lines RISES 6826 -> 7142, so the crate is protected by more
absolute lines than before, and #7080's un-masking accounting was 1398 -> 1398
with zero test names lost. Same shape as #7094's own ironclaw_runner recapture.

ironclaw_sandbox passed unchanged at its arrival capture (87.09%, 3185 / 3657).
The [global] entry is untouched: both moves are crate-to-crate inside the set
the fixed aggregator sees.

* fix(network): compile the test rewrite seam out of production builds (WS3)

Closes the WS3 network row. Also RETRACTS an overstatement I made in this
row's earlier annotation.

CORRECTION FIRST. The earlier note claimed production binaries compile the
seam and honour IRONCLAW_REBORN_TEST_HTTP_REWRITE_MAP at runtime, so anyone
able to set it could redirect all credentialed vendor egress. That was WRONG.
RewriteNetworkTransport::from_env_value already returned UnavailableInRelease
when !cfg!(debug_assertions) (test_rewrite.rs:150), and neither
[profile.release] nor [profile.dist] sets debug-assertions, so a shipped
binary with the variable set REFUSES TO BOOT. It was fail-closed before this
PR. I had read the ungated `mod test_rewrite;` declaration as an ungated runtime
path.

What was genuinely wrong, and is fixed:
1. The guard was a RUNTIME check keyed on cfg!(debug_assertions) - a profile
   proxy, not a build-kind guarantee. A release profile with debug-assertions
   turned on (normal when chasing a production bug) silently re-arms it.
2. The refusal arm had NO TEST. The one guard between a shipped binary and
   redirectable vendor egress was unpinned.

Fix: compile-time exclusion instead of a runtime check. mod test_rewrite and
its four re-exports are now cfg(any(debug_assertions, feature=test-support)),
and default_host_http_egress is a compile-time pair - production builds
PolicyNetworkHttpEgress<ReqwestNetworkTransport> directly, with the rewrite
wrapper absent from the binary. The runtime check stays as defence in depth.

E2E needs no change: those harnesses build DEBUG binaries, so they satisfy
debug_assertions and keep redirecting with no feature flag and no workflow
edit. The feature-forwarding-into-CI risk I flagged earlier does not arise.
test-support is still forwarded composition -> network for a release-PROFILE
build that needs the seam.

Both halves proven rather than assumed:
(a) release refuses - new regression test
    a_set_rewrite_map_activates_only_in_debug_and_is_refused_in_release feeds
    a well-formed map and asserts on profile. Under
    'cargo test --release -p ironclaw_network --features test-support' it
    passes on the UnavailableInRelease branch; under debug 'cargo test -p
    ironclaw_network' it passes on the active branch. 56 passed, 0 failed.
(b) production compiles without the seam -
    'cargo check --release -p ironclaw_reborn_composition' (no test-support)
    is clean, which only compiles if the cfg(not(..)) arm is right.

Also: WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE 129 -> 127. The constant
had drifted ABOVE the real list length; the ratchet is shrink-only so it
passed silently while buying back two unearned slots. Measured off the
compiler (set baseline to 0, read the reported length), identical on main and
on every slice, so pre-existing drift rather than something this PR caused.

cargo test -p ironclaw_architecture: 206 passed, 0 failed.
cargo check --workspace --all-targets: clean.

* docs(coverage): verify the extension_support floor drop is composition, independently

The 82.64 -> 75.31 recapture carried a rationale that was recorded but
explicitly NOT verified. Re-derived it from scratch between the two capture
refs (f946a93fae -> 939af4847d) rather than inheriting the claim:

- 0 test names lost in the crate (158 -> 160 test fns; both new names belong
  to the arriving executor).
- 0 test names lost WORKSPACE-WIDE (13836 -> 13843 test fns, 13752 -> 13759
  unique). This is the check that separates a relocation from a deletion:
  host_runtime's roster drops 156 names over the same range and every one
  reappears in another crate.
- Exactly four files arrived, 1367 source lines, all of them the family-1
  skill-install executor (src/skills/url_install.rs + url_install/{github,
  zip_bundle,bundle}.rs). No pre-existing file left the crate.
- The arithmetic closes with the pre-existing numerator held CONSTANT:
  (6826+316)/(8260+1224) = 75.31% exactly, so the pre-existing code lost zero
  covered lines. The arriving block's own coverage is 316/1224 = 25.82%.

Composition, confirmed rather than assumed. No test regression to fix; the
25.82% arrival is what earns the follow-up already recorded above the entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(host_runtime): collapse a duplicated obligation predicate and quiet a background warn!

Three verified review findings from the #7141 round. Each was confirmed
against the code before being acted on; nothing was changed on assertion alone.

1. obligations/handler.rs — `obligation_supported_before_dispatch` and
   `obligation_supported_after_dispatch` had BYTE-IDENTICAL 19-line bodies
   (verified by exact line-by-line comparison). Both were private, each called
   exactly once, both taking the same `phase` argument. The two names asserted
   a pre/post-dispatch distinction the code never implemented, while the pair
   gates admission of RedactOutput, EnforceOutputLimit and
   EnforceResourceCeiling — so editing one copy alone would have left the other
   stage accepting an obligation the host cannot honour (a fail-open).
   Collapsed to one `obligation_supported`, with the reasoning recorded so the
   pair is not reintroduced.

2. obligations/process_store.rs — `cleanup_terminal` is reached from
   `observe_process_commit` (an async background journal callback, call sites
   at :363/:379/:394), so its `tracing::warn!` violates the repo rule that
   background tasks never use info!/warn! — they corrupt the REPL/TUI display.
   Lowered to `debug!`; the error is still returned to the caller on the next
   line, so nothing is swallowed.

3. reborn_restructure_baselines.rs — the doc table said the
   LAYER_MATRIX_EXCEPTIONS count was "now 11". Recomputed on this ref by
   anchoring on the `= &[` of the value (the `&[LayerMatrixException]` type
   annotation opens a bracket on the same line and silently yields 0): the real
   count is 4, matching WS0_LAYER_MATRIX_EXCEPTION_BASELINE = 4. Corrected.

Verification: cargo check --all-targets -p ironclaw_host_runtime exit 0;
obligation tests 13+26 passed, 0 failed; reborn_restructure_baselines 1 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): a shipped package prompt is an asset, not prose — it was selecting no lane

Review finding on #7141, confirmed empirically before acting. The Markdown
prose carve-out in the planner ran BEFORE the `EMBEDDED_ASSET_OWNERS` lookup.
A prompt is a `.md` file that no package *directory* owns, so a change to
`crates/extensions/packages/*/prompts/**.md` took the prose arm and planned:

    mode=none   crate_buckets=[]   "crate-tree guidance changed: ..."

while its sibling `manifest.toml` in the same package planned `mode=selected`
onto ironclaw_extension_support + ironclaw_extension_host. Prompts are shipped
production output that `ironclaw_extension_support` compiles in, and the
comment above `EMBEDDED_ASSET_OWNERS` names "manifests, prompts, schemas and
built wasm/*.wasm" as exactly what that table owns — so this was the "silent
under-schedule of a change to production output" that comment forbids. 145 of
the 149 `.md` files under `packages/` are prompts.

The rule is keyed on the `prompts/` path segment, not on the asset prefixes.
That distinction is load-bearing: the first attempt yielded to the asset
prefixes wholesale and broke `test-tools/README.md`, which is documentation of
the fixture bundles and is deliberately pinned as prose. Of the four asset
kinds the table owns, only a prompt is Markdown (manifests are .toml, schemas
.json, wasm .wasm), so `.md` asset <=> prompt is exact.

Sabotage-tested in both directions:
  * `_is_package_prompt` -> False (reinstates the bug): RED,
    "AssertionError: 'none' != 'selected'".
  * `_is_package_prompt` -> any .md under an asset prefix (over-broad): RED on
    both the new test and the pre-existing
    `test_markdown_owned_by_no_crate_is_prose`, at `test-tools/README.md`.
  * restored: 52 passed, 51 subtests, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(harness): refresh the latency-runner lockfile after the sandbox consolidation

Review finding on #7141, reproduced before fixing. The latency harness keeps
its own committed `Cargo.lock`, separate from the workspace lockfile, and the
crate consolidation that replaced `ironclaw_scripts` + `ironclaw_process_sandbox`
with `ironclaw_sandbox` never regenerated it. It still carried entries for both
removed packages (lines 3244 and 3602) and the old host-runtime/loop-host
dependency graphs.

Reproduced exactly as reported:

    $ cargo metadata --locked --manifest-path harness/latency/runner/Cargo.toml
    error: cannot update the lock file ... because --locked was passed
    exit 101

so any reproducible invocation of the harness was broken, while the documented
unlocked command silently rewrote the lockfile as a side effect of running.

Regenerated with `cargo update --workspace`, which re-resolves the path
dependencies. Verified after: `--locked` exits 0, the two removed packages are
gone (0 entries), and `ironclaw_sandbox` is present (1 entry).

Note: the re-resolve also carried three registry deps forward
(wasmtime-wasi 46.0.1 -> 47.0.3, wasmtime-wasi-io likewise, wit-parser
0.251.0 -> 0.252.0). That is contained — this lockfile governs only the
standalone benchmark harness and is not the workspace lockfile, and it was
already unusable under `--locked` before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skills): stop rejecting inline bundle installs and stop dropping url conflicts

Review finding on #7141, verified against `dispatch_install` before acting.
Two defects in `resolve_install_input`, in opposite directions:

1. Inline installs lost their bundle. The inline arm required `files`,
   `source` and `source_url` to be ABSENT, so `{name, content, files}` fell
   through to `InputEncode`. That shape is fully supported downstream —
   `dispatch_install` reads `content` and then `parse_install_files`,
   `parse_install_source` and `source_url` off the same object — so a valid
   bundle install was rejected before it ever reached the dispatcher. Those
   three keys conflict with `url`, not with `content`.

2. URL installs silently discarded conflicts. The url arm accepted `url`
   even when `files`/`source`/`source_url` were present, then rebuilt a fresh
   object from the fetched payload — so those fields vanished without a word
   and the caller saw a successful install of something it had not asked for.
   The function's own contract already called that combination an input error
   ("`url` combined with `files`/`source`/`source_url`"); now the code agrees.

Sabotage-tested both guards, and the second round caught a defect in the TEST
rather than the code — worth recording, because it is the failure mode this
program keeps hitting:

  * inline arm made over-strict again: RED on
    `inline_install_keeps_its_bundle_files_source_and_source_url`.
  * url conflict guard removed: initially STILL GREEN. The test used
    `https://example.test/...`, an unroutable host that `validate_skill_url`
    rejects with the SAME `InputEncode` kind — so it passed whether or not the
    guard existed. Rewritten against an allowed `raw.githubusercontent.com`
    URL, where removing the guard now reaches the fetch and fails
    `NetworkDenied`: RED, "left: NetworkDenied, right: InputEncode". The test
    also asserts `usage() == None`, since the guard must reject before any
    egress is consumed.
  * restored: 112 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(capabilities): split host.rs along its six workflows (WS3 Row 2)

`crates/ironclaw_capabilities/src/host.rs` was 4,560 lines — the capability
membrane, where every privileged effect in the stack crosses — fusing all six
caller-facing workflows into one 3,048-line `impl CapabilityHost` block and
held together only by an `// arch-exempt: large_file` waiver on line 1.

It is now the directory module `src/host/`, one file per workflow:

- `invoke`           — workflow 1, `invoke_json`
- `approval_resume`  — workflow 2, `resume_json`
- `auth_resume`      — workflows 3 and 4, `auth_resume_json` / `decline_auth_json`
- `spawn_resume`     — workflow 5, `resume_spawn_json`
- `spawn`            — workflow 6, `spawn_json` + its private `authorize_spawn` fold
- `authorize`        — the one authorization fold all six funnel through
- `resume_support`   — the preflight/authorize/dispatch tail the three resume
                       workflows converge on
- `obligation_seams` — prepare/complete/abort around dispatch
- `error_mapping`    — foreign errors and verdicts renamed into this vocabulary
- `mod`              — the struct, the `CapabilityAuthorizer` seal, the
                       cross-workflow types, the constructors, and the charter
                       table saying which file a new item belongs to

The charter does not follow the CHECKLIST's ranges blindly. Those filed
`evaluate_trust`, `enforce_runtime_policy`, `apply_persistent_approval` and
`seal_authorization` under `invoke_json`, but the call graph shows
`authorize_spawn` and `authorize_resumed` call them too, so they belong with
the fold in `authorize`, not with one workflow. Layering is downward-only: no
module calls a workflow entry point.

Every module clears the 1,500-line gate on its own — largest production file
612, largest of all 910 (`tests.rs`) — so the waiver is **deleted** rather than
carried, and no new waiver is added anywhere. Re-fusing them now trips
`scripts/pre-commit-safety.sh`.

Behavior-free, and no consumer edits: `mod host;` stays private, every workflow
stays an inherent method on `CapabilityHost`, `lib.rs`'s
`pub use host::CapabilityHost;` is untouched, and the 11 unit tests keep their
exact `host::tests::*` paths. Cross-module access is `pub(super)` — 11 methods
and 12 free items, enumerated, never `pub(crate)` and never `pub`. Those 23
signature lines are the only in-body change in the whole split.

Proven no-loss rather than assumed, because a sibling split silently deleted
four tests and five helpers and still went green:

- Bodies sliced by computed item spans and verified byte-verbatim against the
  pre-edit file; all 4,560 lines accounted for (3,040 impl body + 223
  vocabulary + 321 free helpers + 900 tests + imports/headers).
- Item-roster diff vs the pre-edit ref: zero items missing; the only additions
  are the 9 `mod X;` declarations.
- Unfiltered `--list`: 158 tests before, 158 after, names identical; all pass.

One path-keyed gate fired and was repointed, not relaxed:
`scripts/no_panics_reborn_baseline.txt` pinned
`enrich_dispatch_error_credential_requirements`'s `unreachable!` to the old
whole-file path; it now resolves to `src/host/error_mapping.rs`, and
`check_no_panics.py --reborn-baseline` is green.

Guidance travels with the change: the crate's `AGENTS.md` and `CLAUDE.md` now
point at the charter, PROPOSAL §6.5.6 records the split as done, and the
CHECKLIST row is ticked with the per-module line counts.

Verification: `cargo check --all-targets` (workspace) clean; `cargo clippy -p
ironclaw_capabilities --benches --tests --examples --all-features` clean;
`cargo test -p ironclaw_capabilities` 158/158; `cargo test -p
ironclaw_architecture` 130/130; `cargo fmt --check` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(target-arch): retract the "W7 is Wave 5" premise and tighten the ALLOWLIST baseline

Three doc-truth defects found by audit, each verified against the source of
truth before being rewritten.

1. RETRACTED: "W7 is Wave 5". The WS3 verify-row correction on this branch
   justified its tick by claiming nine of ten exceptions carried
   `removes_in = "W7"` and that "W7 is Wave 5". That is false. `W7` is a
   retired July-train milestone label (#5852, 2026-07-09) — one of the dated
   target milestones the exception register stamps on its own entries beside
   `W4.3` and `W6`, as §2.2 states outright. §8.3's dissolution table resolves
   every W7 edge through WS2/WS3/WS4 actions (re-layering, contract moves,
   package moves) and not one through a WS7 physical move, so the label
   carries no wave assignment at all.

   The tick STANDS: it was already earned on the corrected edge-by-edge scope,
   which was derived by reading LAYER_MATRIX_EXCEPTIONS and each edge's real
   owner, not by reading the label. Only the justification was wrong — but it
   was wrong in a way that made Wave 3's remaining scope look smaller than it
   is, so it is retracted in full rather than quietly amended, and the
   surviving W7-labelled entry (`host_runtime → ironclaw_extension_support`)
   now names its real owner: this checklist's own first_party_tools row.

2. The branch contradicted itself: the WS3 heading still read "kills the
   remaining W7 exceptions", restating the same label-as-wave confusion while
   the row below it retracted that reading. Heading reconciled.

3. §8.3's lane-edge row still carried a proof §6.6.3 refuted on 2026-08-03 —
   that the blocker is "the estimate/usage vocabulary … it already does".
   #7067 measured the real blocker as `ResourceGovernor` (10 methods, the lane
   calls 3 and implements none) plus `ResourceError`'s denial cone: a kernel
   carve-out, not a vocabulary move. §8.3 now matches §6.6.3 instead of
   leaving a live false premise for whoever plans that slice.

Also: WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE 127 -> 126, the live count.
Read back off the ratchet by setting the baseline to 0 and letting it report
(126 entries), rather than counted by eye. The branch was carrying one slot of
slack; #7147 tracks the union recount across the sibling PRs.

Verification: cargo test -p ironclaw_architecture — 32 binaries, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(architecture): fix drifted ratchet baselines and fail on slack (#7147)

Two shrink-only ratchets carried untracked slack, and a `<=` ratchet cannot
see it: a baseline sitting ABOVE the live list is an unclaimed budget for
exactly the growth the ratchet exists to refuse.

- `WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE`: 129 recorded, 126 live —
  three free vendor carve-out slots.
- `reborn_struct_test_support_ratchet.rs`: 80/277 recorded, 79/276 live —
  one free frozen dead-code path carrying one suppressed member.

Both baselines are set to the live counts, read off the compiler (zero the
constant, run the gate, read the panic) rather than counted by eye, and both
checks become equalities with a distinct message per direction, so a deletion
that forgets to lower the constant is red instead of silently banked.

Sabotage evidence (each restored to green afterwards):
- allowlist growth: 127 entries vs baseline 126 -> "ALLOWLIST grew to 127".
- allowlist slack: baseline 127 vs 126 live -> "1 entries of UNTRACKED SLACK".
- allowlist negative: entry + baseline raised together (the sanctioned
  carve-out path the message documents) -> green.
- struct growth: a real `#[allow(dead_code)]` field in a new production file
  plus its frozen entry -> "inventory grew to 80 paths / 277 members". With
  the OLD 80/277 baselines that identical input passes green — the defect.
- struct slack: baselines 80/277 vs 79/276 live -> "UNTRACKED SLACK of 1
  paths / 1 members".
- struct negative: an ordinary new production struct with no suppressions ->
  green.

Both gates also now assert they measured something non-zero, so a truncated
const cannot read as success. The WS0 summary table in
`reborn_restructure_baselines.rs` is refreshed: all three of its numbers were
the WS0 capture and every constant they describe had since moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(checklist): strike the egress-threat text the same row already retracted

Review finding on #7141, verified in place. The WS4 egress row contradicted
itself: one bullet retracted the claim that "production binaries compile the
seam and honour IRONCLAW_REBORN_TEST_HTTP_REWRITE_MAP at runtime, so anyone
able to set it can redirect all credentialed vendor egress", and a later
bullet in the SAME row still asserted it verbatim, with a sized remediation
plan premised on it.

The retraction is the correct half: `RewriteNetworkTransport::from_env_value`
returns `HostRewriteMapError::UnavailableInRelease` whenever
`!cfg!(debug_assertions)`, and neither `[profile.release]` nor `[profile.dist]`
enables debug-assertions, so a release binary with the variable set refuses to
boot. Compiling the seam is not honouring it.

Kept as struck history rather than deleted — these rows are append-only — with
the accurate wiring facts preserved and the unsupported conclusion marked as
the thing not to act on. The remediation plan stays (a dev-only seam still
should not compile into production, which is exactly what
.claude/rules/cargo-features.md's `test-support` shape is for) but is re-framed
as hygiene rather than a vulnerability fix, since scheduling it as an open hole
would be acting on the withdrawn premise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(composition): bound composition's absolute production LOC (#7151)

The composition mass gate was share-based and therefore inert twice over.

Poisoned denominator: the metric is composition's fraction of ALL production
crate code, so feature inflow anywhere else improves composition's score while
composition itself grows. Measured on main across two days, composition took
+619 lines of feature inflow against -23 from an entire eviction wave, and its
share still FELL (658 bp -> 634 bp) because the workspace grew faster.

Inert ceiling: 634 bp observed against a 2398 bp ceiling is ~17.4pp of slack —
composition could roughly quadruple untouched. CHECKLIST WS0 records that slack
itself ("constrains nothing").

`[gate].loc_ceiling` bounds composition's production `.rs` LOC directly, on the
same numerator the share metric already computes (one definition, two bounds).
Baseline 44021, a real count on origin/main @ 676d86ce02, cross-checked two
ways that agree exactly: the gate's own `find`-based counter and a
git-tracked-only count, so a stray working-tree file cannot have set it.
Tolerance 150 — deliberately below the +619 inflow this exists to catch.
`loc_nudge_slack = 200` prints the re-ratchet reminder at every wave close.

The keys are REQUIRED, not optional-with-a-default, in both the shell schema
check and `reborn_restructure_baselines.rs`, so the binding metric cannot be
disarmed by deleting three TOML lines. The Rust record also asserts the ceiling
BINDS — a ceiling more than one nudge window above the recorded count fails,
which is the specific way the share ceiling went inert.

Sabotage evidence (all restored to green):
- +619 LOC into the real composition crate -> gate exit 1, "ABSOLUTE MASS
  EXCEEDED: composition holds 44640 production LOC, 469 over the effective
  ceiling of 44171" — while the share metric printed "NUDGE: mass is 17.56pp
  below ceiling", i.e. nowhere near firing. That contrast is the defect.
- delete `loc_ceiling` -> shell exit 1 "[gate].loc_ceiling must be an integer,
  got '<missing>'"; Rust test panics in `integer()`.
- `loc_ceiling = 0` -> exit 1, "must be greater than 0 — a zero absolute
  ceiling is a disarmed gate, not a bound".
- `loc_ceiling = 60000` -> Rust test red, "15979 LOC of unclaimed headroom,
  more than the 200-LOC nudge window".
Negative cases (must NOT trip, and do not):
- +619 LOC into ironclaw_webui (feature inflow elsewhere) -> exit 0.
- +120 LOC of routine wiring in composition (inside tolerance) -> exit 0.

Self-test grows 66 -> 76 assertions; L2 pins the poisoned-denominator scenario
end to end (share improves 30.00% -> 26.57% while the absolute bound fires),
and C11 pins that the committed ceiling itself is not slack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(host_runtime): shed the catalog defaults downward (WS3 row 3)

CHECKLIST WS3 row 3 / PROPOSAL §6.5.9 asked for "extension
binding/catalog defaults → `extension_host`". That destination is
structurally impossible for the catalog half and the binding half is
refuted outright; both docs are corrected in this commit and the row is
closed against the corrected condition.

Catalog defaults — moved DOWN, not up. `ironclaw_host_runtime` is itself
a production consumer of both defaults (memory_native_extension.rs:96
and :101, inside the bundled-memory package builder §6.5.9 keeps), and
`ironclaw_extension_host` is layer `products` already depending on
`host_runtime` (`kernel`), so moving up would create an illegal
kernel→products edge and a Cargo cycle. Each default goes instead to the
crate that owns the vocabulary it enumerates:

  * `default_host_port_catalog` → `ironclaw_host_api::host_port`, beside
    the three port constants it lists. Its unit test moves with it.
  * `default_host_api_contract_registry` → `ironclaw_extensions::host_api`,
    beside the one contract it registers.

89 references across 30 files repointed; no `pub use` shim left in
`ironclaw_host_runtime` (§11.3), which keeps only the RootFilesystem-bound
`discover_extensions_*` fns that apply the defaults (extension_contracts.rs
151 → 99 lines). No crate gained a dependency, so LAYER_MATRIX_EXCEPTIONS
is unchanged at 4.

Binding — REFUTED and struck, not deferred. `RuntimeLaneExecutor`
(`pub(super)`) and `RuntimeLaneRequest` (`pub(crate)`) have zero
references in any .rs file outside `crates/ironclaw_host_runtime/`;
shedding `services/extension_tool_binder.rs` requires widening both to
`pub`, contradicting §6.5.9's own Keeps clause ("the closed
RuntimeLaneExecutor + lane adapters"). The binder's `Arc<dyn
LanePackageBinder>` handle already delivers the encapsulation the shed
was meant to buy.

Regression coverage: the moved
`default_catalog_registers_egress_storage_and_audit_ports` guard pins the
port set at its new home, and the host_runtime
`host_api_contract_composition` suite pins the contract registry through
production discovery. Both sabotage-verified — dropping the audit port
fails with "default catalog must contain host.events.audit"; dropping the
contract registration fails with UnknownHostApi
{ id: "ironclaw.capability_provider/v1" }.

Guidance travels with the change: the three crate AGENTS.md files, ADR
0002, and the memory-profiles contract doc all name the new homes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(operator): name the port call in LlmKeyStoreError::Store

Review finding on #7141. All five `OperatorSecretValueStore` calls — put,
contains, handles, read, delete — collapsed into one bare
`Store(OperatorSecretValueStoreError)`, so a store failure kept its stable
reason but lost which operation produced it. Carries a `&'static str`
operation name beside the source now; the delete-path log line in
`llm_config_service` emits it as `secret_store_operation`.

`&'static str` rather than an enum on purpose: it is diagnostic only, nothing
branches on it, and a caller that needs to branch should match the source.

The existing five-operation test was updated rather than replaced, and
STRENGTHENED — it now zips each error with the port call that produced it and
asserts the name, which is the property the variant exists to provide.

Sabotage-tested, and the first attempt was a false pass worth recording:
mislabelling `read` as `put` appeared green because `cargo fmt` had reflowed
the struct literal across four lines, so the single-line search string
silently matched nothing. Re-applied against the real text: RED,
"assertion `left == right` failed: store failure must name the port call it
came from, left: \"put\", right: \"read\"". Restored: 153 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(architecture): inventory same-layer dependency edges (#7149)

`layer_allows_dependency` is reflexive, so an edge between two crates in the
same layer is legal by construction: it never reaches the violation branch, no
`LAYER_MATRIX_EXCEPTION` can exist for one, and the matrix cannot see it.
PROPOSAL §8.1's 2026-08-02 amendment records the hole and measured 72 such
edges; WS10 has no gate for it.

Measured on origin/main @ 676d86ce02: 391 workspace normal edges, 73 of them
same-layer (34 substrates, 15 kernel, 10 products, 7 loops, 5 contracts, 1
runtimes, 1 app). Recounted, not inherited — #7149 quotes 68 and the amendment
72, from earlier trees. Counting method: deduplicated (crate, dependency) pairs
from `cargo metadata --no-deps` where both ends declare the same layer and the
dependency kind is `normal` — the same filter the layer-matrix gate applies, so
the two measure one graph.

`SAME_LAYER_EDGE_INVENTORY` is the missing default guard, shaped like
`LAYER_MATRIX_EXCEPTIONS`: complete (a 74th edge is red), non-stale (a deleted
edge is red), shrink-only in BOTH directions (growth is new coupling, slack is
an unclaimed budget for it — #7147's lesson applied from the start), and
tracked (owner = the consumer's §5 family, `decided_in` = the CHECKLIST
workstream that owns it; placeholders count as missing). The doc comment is
explicit that `decided_in` is not a deletion promise: some same-layer edges are
permanent by charter.

Second rule: a downward re-layer must land with a consumer-side pin.
`CRATE_LAYER_ORIGINS` freezes each crate's FIRST declared layer, derived from
`git log` over all 67 layered crates rather than assumed — exactly one downward
re-layer has ever happened (`ironclaw_extensions` loops -> substrates, #7094),
alongside two promotions (`hooks`, `runner`) which need no pin because moving up
narrows reach. A live layer below the origin is therefore a permanent,
detectable demotion, and the gate then demands a `DowngradePin` whose frozen
consumer set is enforced on every commit. A layer ceiling would not bite:
`extensions` moved down precisely so kernel/runtimes could reach it, so only an
explicit consumer set constrains anything.

Sabotage evidence (each restored to green):
- NEW same-layer edge `slack_extension -> host_ingress` (products->products):
  this gate RED with "NEW SAME-LAYER DEPENDENCY EDGE(S)" and the ready-to-paste
  row, while `reborn_workspace_crates_declare_layers_and_follow_layer_matrix`
  on the IDENTICAL input stayed GREEN. That contrast is the defect.
- stale row (drop `threads -> safety`) -> "names edges that no longer exist".
- slack (baseline 74 vs 73) -> "1 entries of UNTRACKED SLACK".
- growth (baseline 72 vs 73) -> "inventory grew to 73 (baseline 72)".
- untracked entry (`decided_in: "TBD"`) -> "missing `decided_in`".
- demote `host_ingress` products -> substrates, reproducing #7143 ->
  "DOWNWARD RE-LAYER WITHOUT A CONSUMER-SIDE PIN".
- new consumer of the demoted `extensions` -> "reach taken after the loops ->
  substrates demotion without review".
- a permitted consumer that stops depending on it -> stale-pin failure.
Negative cases (must NOT trip, and do not):
- a legitimate CROSS-layer edge (operator products -> threads substrates).
- a PROMOTION (host_ingress products -> app) demands no pin.
- the sanctioned deletion: drop the edge, its row, and the baseline together.

Scanned-something guards throughout: floors on layered-crate and edge counts,
a non-empty live set, non-empty inventory, duplicate-row rejection, unknown
declared layers fail loudly, and every pinned consumer must resolve to a real
layered package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* revert(skills): restore the hidden-field install guards — the review finding was wrong

Reverts the resolver change from b57ac8e59f. That commit acted on a review
comment claiming `resolve_install_input` wrongly rejected inline bundle
installs and wrongly dropped url-path conflicts. Both halves are REFUTED by
pre-existing integration tests I failed to consult before changing behaviour,
and CI caught it: `first_party_builtin_tools` went 205 passed / 2 failed.

  * `builtin_skill_install_rejects_hidden_url_install_fields` asserts inline
    `content` + `files` / `source` / `source_url` is REJECTED with InputEncode
    and nothing is written to disk. My change accepted it.
  * `builtin_skill_install_url_path_ignores_caller_supplied_hidden_bundle_files`
    asserts url + caller `files` SUCCEEDS with `files_installed == 0` — the
    caller's files silently dropped. My change rejected it.

The asymmetry is deliberate, not a defect. `files`, `source` and `source_url`
are PROVENANCE fields the resolver sets itself on the url path; a caller may
never supply them. Accepting them inline would let a caller forge provenance —
claim an inline skill came from a trusted URL — or smuggle bundle files past
the fetch. `dispatch_install` reading `files` is not evidence a *caller* may
send it: that support exists for the rewritten payload this resolver builds.

My two unit tests encoded the wrong contract and are removed rather than
adjusted. The reasoning is now a comment on the match itself, naming both
integration tests, so the next reader does not re-propose either change.

After: first_party_builtin_tools 206 passed, 0 failed.

Lesson recorded because it is the general one: "verify first" means checking
for existing tests that pin the behaviour, not only reading the downstream
function's shape. I checked `dispatch_install` and stopped too early.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(architecture): census LLM-vendor names in the contracts family (#7150)

§12.11 D-E amended §8.2 to sanction LLM-vendor administration vocabulary in
`ironclaw_product_contracts::operator_llm` — "that module and nowhere else in
the contracts family" — and owed a vendor-name census with the amendment,
because `reborn_extension_specificity.rs` cannot see this surface at all:
`nearai` is removed globally by its TERM_COLLISIONS and `codex`/`openai`/
`anthropic`/`claude`/`gpt` are not derived terms in any package manifest. D-E
says so itself: without the census "the bound is review discipline rather than
enforcement". The census existed on no ref. This is it.

Scope is the whole contracts family, not one file: "nowhere else in the
contracts family" is a claim about the family, and a census scoped to
`operator_llm.rs` cannot check it. Roots resolve through `cargo metadata`
manifest paths, so the WS7 family move cannot take it dark.

⚠ FINDING — D-E's "nowhere else" is not true today. The census turns up a
second LLM-vendor surface D-E did not know about: `ironclaw_common::llm_costs`,
a per-model price table naming 9 distinct vendors across 91 occurrences
(claude, gpt, sonnet, opus, haiku, codex, mistral, deepseek, llama), invisible
to the specificity scanner for exactly the same reason `operator_llm` is. The
gate does not delete it — that is a product decision — but it names it, freezes
it, and refuses to let it grow, which the honour-system could not. Two further
matches are classified rather than waved through: `prompt_envelope`'s
"you are chatgpt" is a safety DENYLIST (removing the term weakens the
detector), and `attachment_format`'s `opus` is the Opus AUDIO CODEC, handled by
a path-scoped term-collision carve-out that itself fails the day it stops
matching.

D-E's three bounds are enforced as numbers AND as an exact roster, so a rename
that swaps one vendor for another cannot pass with the counts unchanged:
6 vendor-named DTOs, 3 vendor-named methods, 2 distinct vendors. Extraction
finds exactly D-E's stated 3 methods + 6 DTOs.

Baselines measured by the gate's own scanner on origin/main @ 676d86ce02, so
the baseline and the measurement can never disagree about method: operator_llm
16 occurrences / 2 vendors; llm_costs 91 / 9; prompt_envelope 1 / 1. Counts are
equalities — growth is new coupling, slack is an unclaimed budget for it
(#7147).

The comment/`#[cfg(test)]` strippers are LOCAL, not added to `ratchet_support`:
the shared `strip_comments_and_strings` blanks string CONTENTS, which a vendor
census must not do (a provider id hides in a string literal), and changing the
shared lexer would put a behaviour change under thirty other ratchets to serve
one caller. Both have fixtures.

Sabotage evidence (each restored to green):
- a SEVENTH vendor DTO (`AnthropicLoginStart`) -> RED "NEW VENDOR-NAMED ITEM";
  the specificity scanner on the IDENTICAL input stayed GREEN.
- a FOURTH provider login (`start_gemini_login`) -> RED.
- a vendor name in an un-censused family file (`host_api`) -> RED "LLM-VENDOR
  NAME IN AN UN-CENSUSED CONTRACTS-FAMILY FILE"; specificity scanner GREEN.
- growth inside a censused scope (one more model row) -> RED census drift.
- slack (census records 95 against 91 live) -> RED census drift.
- a RENAME `CodexLoginStart` -> `GeminiLoginStart`, counts unchanged -> RED.
- a narrowing that forgets to lower the ceiling -> RED "defines 5 vendor-named
  DTOs; §12.11 D-E bounds it at 6".
- removing the Opus MIME alias -> RED stale carve-out.
- emptying LLM_VENDOR_TERMS -> RED "would pass having looked for nothing".
Negative cases (must NOT trip, and do not):
- a non-vendor production addition to the contracts family.
- a vendor name added inside a `#[cfg(test)]` block and a doc comment.

A matcher bug was caught by writing the fixtures first: `_` had been treated as
identifier-internal, so `start_nearai_login` did not match `nearai` and the
surface read as six items instead of nine. `_` is a word separator; `llama`
still does not fire inside `ollama`. Both directions are pinned in the
self-test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(architecture): make the two new gates visible to CI's test-name filter

Both gates added in this PR were INERT in one of the two lanes that run them,
and the sabotage suites did not catch it because they invoke cargo directly.

`code_style.yml` runs `cargo test -p ironclaw_architecture reborn`. That
argument is a **test name** filter, not a path filter — the file being called
`reborn_same_layer_edge_inventory.rs` selects nothing. Under the exact command
CI uses, both binaries reported `running 0 tests`. Measured, then fixed, then
re-measured: 0 -> 6 and 0 -> 5.

Every test function now carries the `reborn_` prefix the crate's other 45
filter-visible tests already use, and both module docs record the trap so the
next gate added here does not repeat it. The test roster was diffed before and
after the rename: 11 functions, 11 functions, none lost.

Context for reviewers, measured while diagnosing: the crate has 217 `#[test]`
functions and that filtered step runs 45 of them. The other 172 are NOT dark —
`reborn-tests.yml`'s crate-bucket lane runs `cargo test -p ironclaw_architecture
--all-targets` with no filter, so they execute there. The filtered step is a
narrower smoke, not the only lane. Naming these gates to the convention means
they run in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(target-architecture): record the four enforcement additions and two findings

Target-architecture docs are the single source of truth, so each gate and each
measurement in this PR lands here rather than only in a PR body.

CHECKLIST WS10 gains three rows — the same-layer inventory, the downward
re-layer pin (#7149), and D-E's vendor census (#7150) — each carrying its
baseline and counting method.

CHECKLIST's WS10 composition-ratchet row is answered rather than left standing:
"the composition-mass ceiling is already ~17.4pp slack and constrains nothing"
could never be fixed by re-capturing `ceiling_bp`, because the share metric's
denominator is every other crate's production code. The original sentence is
kept as the record of why; the note adds the absolute bound (#7151) and the
+619/-23 measurement that motivated it.

PROPOSAL §8.1 rule 1's amendment is annotated: the plane it measured is now
inventoried and enforced, and the recount is 73, not 72 — the kernel and loops
buckets moved.

PROPOSAL §8.2's amendment and §12.11 D-E both carry the census result, including
the part that contradicts the ruling: "nowhere else in the contracts family" is
not true today, because `ironclaw_common::llm_costs` names 9 vendors across 91
occurrences and was invisible for exactly the reason D-E gives for
`operator_llm`. Recorded as a frozen residue with the obvious candidate fix
(move the cost table beside the `llm` providers, which §8.2 already sanctions),
not silently corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(capabilities): make the auth-required enrichment total, dropping its unreachable!

The host.rs split moved `enrich_dispatch_error_credential_requirements` into
`host/error_mapping.rs`. The code was byte-identical to its pre-split form
(`host.rs:3649` at the merge base), but the move made the file a *changed*
file, so the changed-lines panic scanner
(`check_no_panics.py --base <base> --head HEAD`) scanned it for the first time
and flagged the `unreachable!("matched AuthRequired above")`.

The scanner was right that the panic was there, and the honest fix is to remove
it rather than annotate it. The function destructured `error` twice: once by
`ref` to inspect, then again by value to take ownership, with an `unreachable!`
covering the second match that the first had already proven. `AuthRequired` has
exactly three fields, so a single by-value `match` with a guard is total: the
guard only borrows, so a non-enriching outcome falls through to `other` with
`error` un-moved, and the enriching arm rebuilds the variant from parts it
already owns. No branch is left to assert.

Behavior is unchanged and pinned: 158/158 `ironclaw_capabilities` tests pass,
including the six `enrich_*` unit tests and the caller-level
`invoke_json_*`/`auth_resume_json_*` contract tests. Sabotage-tested — dropping
the derived requirement from the enriching arm fails
`enrich_fills_empty_from_single_credential_obligation` with `left: 0, right: 1`,
so the guard checks what it claims.

Both scanner modes verified, because they disagree by design: the changed-lines
mode honors only inline `// safety:` comments and never reads the baseline,
while `--reborn-baseline` rejects stale entries as well as new ones. Removing
the panic therefore made the baseline row stale, so it is deleted in the same
commit — a real downward ratchet, 51 -> 50 reviewed invariants, not a repoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(capabilities): return the authorization policy helpers to authorize

Two review findings on the host.rs split, both confirmed against the code.

`error_mapping`'s module doc says outright that nothing in it may make a policy
decision — "it only renames one that was already made". Three items contradicted
that: `WITNESS_DEFAULT_TTL` and `witness_deadline` decide how long a sealed
authorization witness stays valid, and `permission_mode_allows_persistent_approval`
classifies which permission modes an "always allow" decision may upgrade. Both
are authorization policy. They move to `authorize.rs`, which already owns the
verdict, leaving `error_mapping` as the translation-and-cleanup seam it claims to
be. Their only callers were `authorize.rs` and the test module, so this is a
visibility-neutral move: still `pub(super)`, no widening.

Verifying that finding surfaced a second defect the review did not name, in the
same class as the `authorize`/`evaluate_trust` doc slip reported beside it. The
split had fused two doc comments onto one item: the ten-line paragraph describing
`permission_mode_allows_persistent_approval` sat directly above
`WITNESS_DEFAULT_TTL`, so the constant carried someone else's documentation and
the function it described had none at all. Each doc is reattached to its own item.

The reported slip is fixed the same way: the pre-dispatch authority-fold paragraph
was left on `evaluate_trust` while `authorize` — the function it describes — had
no doc comment. Moved onto `authorize`.

Text is carried verbatim in every case; no doc was reworded, and no behavior
changed. `ironclaw_capabilities` 158/158 pass, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(docs,ci): correct the guest WIT path and delete a test that never ran

Two confirmed review findings, both verified before acting.

`building-a-channel.mdx` told channel authors to point `wit_bindgen::generate!`
at `../../crates/ironclaw_wasm/wit/channel.wit`. From a guest crate at
`crates/extensions/packages/<name>/wasm-src` — the layout the page describes and
the one the Slack package uses — that resolves nowhere. The correct relative path
is four levels up, `../../../../ironclaw_wasm/wit/channel.wit`, confirmed with
`os.path.relpath` against the real tree. The trailing "Adjust path as needed"
hint is replaced by a comment naming the directory the path is relative to, so
the reader can tell when it needs adjusting rather than guessing.

`test_reborn_pr_test_plan.py` defined
`test_shared_e2e_harness_remains_an_explicit_mapping_error` twice in one class,
at lines 368 and 546, with byte-identical bodies. Python keeps the last binding,
so the first never ran — a test present in the file and absent from the suite.
Removed the shadowed copy and kept the live one.

Proven rather than assumed: the suite reports 52 passed / 51 subtests both before
and after the deletion, which is what confirms the removed definition was
contributing nothing. No assertion was dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(host-api): pin the process-sandbox capability literal as a valid id

Partly accepts a review finding. The reviewer asked for a typed
`CapabilityId` accessor beside `PROCESS_SANDBOX_CAPABILITY_ID`, on two grounds:
the comparison sites are stringly, and the literal is never validated by
`CapabilityId::new`.

The second ground is real and is the one worth closing. The constant is compared
as a `&str` on two *gating* paths — the kernel spawn check
(`production.rs:1580`) and the process executor's routing check
(`process_executor.rs:185`) — and a malformed literal would not fail there: the
comparison would simply never match, so sandbox plans would quietly stop being
recognised. That is a fail-open, and nothing in the tree pinned the literal's
validity.

The proposed accessor is declined, with the reason. `CapabilityId::new` is
fallible, so the accessor must return a `Result`, which puts error handling on
two hot gating comparisons to re-derive a fact that is fixed at compile time —
and it would not make those sites typed anyway, since both compare against a
value they already hold as `&str`. A test costs nothing at those call sites and
closes the same gap: the literal is now checked to parse, and to round-trip
through `CapabilityId::as_str` unchanged.

Sabotage-tested: mutating the literal to `"system.process sandbox.run!"` fails
the guard, so it checks what it claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(ci): pin the pre-commit staged-path selector after the WIT move

Wave 3 moved the WIT directory into its owning crate, which changed
`.githooks/pre-commit`'s staged-path selector from `^wit/` to
`^crates/ironclaw_wasm/wit/`. A path-literal gate fails silently: move the
directory it names and the hook keeps exiting 0, so version-bump checks stop
running and nothing reports it. Repo guidance requires a behavior-changing hook
to land with a regression test; there was none.

The test matches through `grep -E` so it sees the hook's own regex dialect
rather than Python's, and it extracts the pattern from the hook instead of
restating it, so a restructured selector fails loudly rather than leaving the
test asserting a copy of itself. Wired into the reborn-tests step that already
runs `test_reborn_pr_test_plan.py` — `scripts/test-pre-commit-safety.sh`, the
existing precedent for a hook self-test, is referenced only in a comment and is
run by no workflow, so following it would have added a test nothing executes.

Writing it surfaced a pre-existing finding: the hook also gates `channels-src/`
and `tools-src/`, and neither directory exists — here or on `origin/main`
(`git ls-tree origin/main` returns neither), so they are dead literals this
branch did not create. `check-version-bumps.sh` carries the same two prefixes.
Asserting them away would make this branch red for someone else's debt, so they
are pinned as a known-missing set instead: a *new* dead prefix fails the test,
while the existing two are recorded where the next reader will see them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(ci): re-seed composition loc_ceiling at the merged-tree count (44392)

Merging main @ be33ae138f into this branch brought #7062's +371 production
LOC of composition wiring, and the new absolute-mass gate correctly went
red against its own merge context (44392 observed vs 44021+150 effective
ceiling — the exact failure CI showed). Re-measured on the merged tree with
the gate's own counter and re-seeded to current, not padded, per the
manifest's ratchet convention. Gate + its 76-case self-test green locally;
both new architecture gates (same-layer inventory, vendor census) pass on
the merged tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ci): move the absolute-mass record with its re-seeded ceiling (44392)

The nudge-window assertion refused a ceiling that moved without its
record (44392 - 44021 = 371 > 200) — which is precisely the binding
property this PR adds; the previous commit re-seeded the manifest and
left the test's record behind. Full ironclaw_architecture suite green
on this tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS5: repoint conversations' turn vocabulary to host_api; record the sever fork

The `conversations -> turns` sever cannot land as specified. CHECKLIST WS5 and
PROPOSAL §6.4.2/§8.3 all name "the product tier" as the destination for the
inbound submit orchestration; §8.2's own retained named rule
("untrusted-ingress paths never construct trusted trigger submitters") and the
two gates that implement it forbid exactly that. §6.4.2 also contradicts itself
in one paragraph: its charter retains the trusted-trigger submitter while its
Deps clause drops the coordinator that submitter holds.

Landed here — the half that is fork-independent and required by every
resolution: the ten `host_api`-owned turn names this crate uses now import from
`ironclaw_host_api::turn` instead of travelling through the `ironclaw_turns`
re-export hop (§11.2.4 two-import-paths, the same repoint the WS3 mcp row took
for free on `ResourceReceipt`). No manifest change, no behaviour change; the
residual is now exactly two turn-crate-owned names (`SubmitTurnResponse`,
`TurnError`) plus the orchestration.

Recorded — measurements, sizing, the destination refutation and both candidate
resolutions with their costs, on the CHECKLIST WS5 row, in PROPOSAL §6.4.2, and
in the exception entry's own `reason`. The register is unchanged at 4: the edge
still exists, so deleting its entry would fail the staleness gate and lie.

Verification: cargo test -p ironclaw_conversations --no-fail-fast 97/97;
cargo test -p ironclaw_architecture --no-fail-fast 211/211;
clippy --all-targets --all-features -D warnings clean on both;
cargo check --workspace --all-targets clean (one pre-existing dead_code warning
in ironclaw_extension_support, present on the base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS5: record the trigger-poller bound mapping and the step-1 blocker

Fork resolved by the coordinator under delegated authority: the "product tier"
prescription is struck (THE CODE WINS over §6.4.2/§8.3), and the resolution is
delete-the-dead-half + move-the-live-half to composition. Executing it stops at
step 1.

Bound mapping (the review-critical artefact): production wiring instantiates C
as RebornFilesystemConversationServices. ConversationContentRefMaterializer
needs only ConversationBindingService and invokes exactly one method
(resolve_or_create_binding_with_trusted_scope). The InboundConversationService
bound exists solely for trusted_trigger_fire_submitter -> InboundTurnService,
which invokes all six of its methods -- so the trait is not dead and the
submitter cannot move without the orchestration it wraps.

STOP at step 1, per the resolution's own stop condition. handle_inbound_turn is
production-uncalled but not dead: deleting it and running the unfiltered suite
surfaced 37 E0599 across 22 test functions (33 in tests/inbound_contract.rs, 4
in inbound.rs's module) plus the compiler's own "variant Untrusted is never
constructed". Among them,
untrusted_trigger_adapter_records_product_inbound_not_scheduled_trigger is the
sole executable proof that an untrusted adapter cannot spoof TrustedTrigger
classification. Deletion refused; no test weakened. Deletion reverted, tree
byte-identical, 97/97 green.

Also recorded: the workable shape (move both entry points + all 22 tests, gate
the untrusted entry behind composition's existing test-support feature) at its
true cost of ~540 production + ~2,224 test lines, against the ~62-100 the move
was scoped at; and the one residue that must be settled first, SubmitTurnResponse,
which sits in the RETAINED ledger contract rather than in the moved code and so
needs to descend to host_api::turn before the manifest dep can drop.

Verification: cargo test -p ironclaw_conversations --no-fail-fast 97/97;
cargo test -p ironclaw_architecture --no-fail-fast 32/32 binaries green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS3: lanes consume a narrow reserve/reconcile/release port (#7067)

Dissolve the last two `runtimes -> kernel` layer-matrix exceptions,
`ironclaw_mcp -> ironclaw_resources` and `ironclaw_sandbox ->
ironclaw_resources`, by inverting the seam rather than relocating the
kernel's budget authority (PROPOSAL 8.3 row 7's 2026-08-04 amendment
rules the relocation out).

`ironclaw_host_api::resource` declares `RuntimeResourceBudget` — reserve
/ reconcile / release only, typed on shapes that crate already owned —
plus a narrow classified error (`RuntimeResourceError` +
`RuntimeResourceErrorKind`). `ironclaw_resources` implements it over any
`ResourceGovernor` as `GovernorRuntimeBudget` and owns the
`ResourceError` projection, which is subtractive by design: the
classification survives whole (LimitExceeded and RequiresApproval stay
distinct) while account/limit/dimension values stop in the kernel. Both
lanes drop `ironclaw_resources` from `[dependencies]`; it stays a
dev-dependency so the lane suites keep driving the port over the real
governor.

Behavior-free at the effect level: same authority calls in the same
order, and `model_visible_cause` is byte-identical because the
projection carries the authority's own rendering.

Regression coverage at the lane seam: the existing budget-denial tests
now assert classification and preserved wording; new tests pin that an
approval pause stays distinct from a hard denial, and that the
prepared-reservation path reuses a matching hold and rejects a
mismatched one before any side effect (that path had no lane-seam
coverage before).

LAYER_MATRIX_EXCEPTIONS 4 -> 2 and WS0_LAYER_MATRIX_EXCEPTION_BASELINE
lowered by 2 in the same change. Closes #7067.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS5: descend SubmitTurnResponse to host_api::turn; record the port-inversion shape

Coordinator decision: NOT relocation. Orchestration stays in
ironclaw_conversations; the crate will declare a narrow submission port that
composition implements with the coordinator handle it already constructs
(dependency inversion, type-placement rule 2). Both earlier candidates struck.

Pre-build gate verification (ordered before any code) - BOTH PASS:
(a) trusted_trigger_submit_request_minting_stays_worker_owned polices the string
    "TrustedTriggerSubmitRequest {" - the triggers-owned fire request - and says
    nothing about SubmitTurnRequest. No refutation.
(b) Six-method bound mapping re-run against the port surface: the coordinator
    handle is touched at exactly ONE call site (submit_turn, inside
    submit_or_replay), so the port is a one-method trait. TurnErrorCategory and
    adapter_status_code are named only in this crate's TESTS, never in
    production, so the port error needs three equivalence classes, not the
    kernel denial cone: rotate+retryable {ThreadBusy, Unavailable,
    AdmissionRejected(TenantLimit|Unavailable)}; keep+retryable
    {CapacityExceeded, Conflict}; keep+rejected {everything else}.

Landed here - the precondition: SubmitTurnResponse descends from
ironclaw_turns::response to ironclaw_host_api::turn. Every field type was
already that module's, so zero new dependencies; re-exported through
ironclaw_turns' already-documented host_api::turn facade, so no call site
outside the two crates changes (no-shim rule satisfied via a sanctioned facade).

Effect: traits.rs, types.rs, memory.rs and conversation_state_store.rs are now
completely free of ironclaw_turns - the retained ledger contract no longer names
the kernel. Production residue is exactly the orchestration in three files
(inbound.rs, trusted_trigger.rs, error.rs), which the port removes.

Also recorded for the port build: product_context::{InboundClassification,
resolve_inbound} is turns-owned and must become a conversations-declared typed
classification (it is the trust distinction the spoof-proof test pins); and the
crate's AGENTS.md/CLAUDE.md invariant naming ironclaw_turns::TurnError must be
amended in the port change rather than silently contradicted.

Verification: conversations+turns+host_api 553/553; ironclaw_architecture
207/207; clippy --all-targets --all-features -D warnings clean on all four;
cargo check --workspace --all-targets clean; fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS10: convert the loud path-keyed gates to inventory keying before the family moves

Executes the WS10 CHECKLIST row "Loud path-pattern inventory updated with the
moves". #6946/#6996 fixed the SILENT path-keyed gates; the loud ones were
deferred because they fail visibly at the `git mv` — but only by demanding a
lockstep sweep of ~450 literals in the same commit that moves 65 crates.

Gates keep their readable flat `crates/ironclaw_x/...` spelling and now RESOLVE
it through the crate inventory: the literal is a crate NAME plus an in-crate
remainder, not a directory path. On today's tree resolution is the identity
(the behavior-free proof); after Wave 5 the same literal resolves to the new
directory with no edit.

- ratchet_support gains the Rust half of scripts/ci/lib/crate_tree.py's rule
  (crate_directories / crate_directory / crate_dir / crate_path /
  resolve_crate_relative / owning_crate_name), pinned equal to the Python
  inventory by the new reborn_crate_inventory.rs.
- Converted: ~108 literals in reborn_dependency_boundaries.rs, ~215 in
  reborn_extension_specificity.rs, 79 FROZEN_PATH_COUNTS in
  reborn_struct_test_support_ratchet.rs, plus the single-site gates and
  reborn_sealed_evidence_mint_ratchet's owning_crate.
- Scripts and workflows: 28 WebUI-frontend sites, docker.yml's VERSION
  extraction, nightly-deep-ci's mutation target, check-version-bumps.sh,
  reborn_pr_test_plan.py, classify-test-scope.sh, cut_ironclaw_release.py,
  quality_gate_strict.sh, run-hermetic-deterministic-suite.sh,
  run-reborn-webui.sh, scrub-artifacts.sh, audit_surface_inventory.py,
  slack_helpers.py — all via the new scripts/ci/crate-dir.sh, and every
  rewrite pinned in scripts/ci/ws12_workflow_contracts.py.

Four defects surfaced, all live on the flat tree, none needing Wave 5:
1. reborn_extension_specificity.rs's fail-open registration guard joined
   crates/<package name>/ and so has been checking ZERO crates since WS2
   colocation renamed the directories.
2. reborn_dependency_boundaries.rs:37/:89 would have skipped every crate under
   a move, both behind a `continue`.
3. reborn_sealed_evidence_mint_ratchet::owning_crate took the first component
   under crates/, mis-attributing mint sites in a security-critical census.
4. Production: ironclaw_extension_host/build.rs derived the repo root with two
   .parent() hops, then read <root>/skills. One family level deeper that root
   is crates/, and the script writes [] for both bundles and returns Ok(()) —
   a green build shipping a binary with no bundled Reborn skills. Fixed, and
   reborn_build_script_roots.rs now bans the counted-hop idiom.

Evidence, both directions on the same tree (crates/substrates/{ironclaw_llm,
ironclaw_webui}, manifests repointed): base main 200 passed / 7 failed;
this change 219 / 0; back on the flat tree 219 / 0. cargo fmt --check and
clippy clean; eleven script self-tests green.

The CHECKLIST row is amended in the same diff and stays OPEN — the residue that
must travel with the move (Cargo manifests, wit_bindgen paths, include_str!,
the panic baseline, the Dockerfile) is listed there verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS10: pin the hermetic suite's WebUI frontend resolution

`scripts/ci/run-hermetic-deterministic-suite.sh` resolves the WebUI frontend
directory through `scripts/ci/crate-dir.sh`; without a pin, a literal
`crates/ironclaw_webui/frontend` regressing back in is a silent break — the
suite would `cd` into a directory that used to exist and report nothing wrong
until the frontend build actually runs.

The assertion matches the exact removed literal (with the `/frontend` suffix)
rather than the bare crate name, so it does not trip on its own explanatory
prose, and it also requires `resolve_webui_frontend_dir` to still be present.

Regression test: `bash scripts/ci/test-hermetic-test-process.sh` -> OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): restore the entry tail the exemptions-union resolution dropped

Git kept the shared issue/review_after tail of both sides' final entries
outside the conflict markers; the union reorder handed it to the wrong
block, leaving the tool_payloads.rs entry (#166) without its policy
fields. Validated with CI's own invocation this time
(--validate-manifest-only), not just a TOML parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS10: classify the repo-root scripts this PR touches in the test planner

`Detect Reborn test scope` failed on this branch:

    Reborn PR test planner failed: unmapped test or CI path: scripts/check-version-bumps.sh

Same shape as the two planner gaps the WS10 CHECKLIST row already records:
`scripts/ci/reborn_pr_test_plan.py` fails closed on any path it has no rule
for, so an unclassified class makes "never edit this file" the only satisfiable
behaviour — and the failure takes `Tests (Reborn)` down with it, since every
downstream lane reports `skipping` when the scope job is red.

Repo-root `scripts/` is deliberately not prefix-classified, so each file needs
a decision recorded beside the constant. Four were missing:

- `scripts/check-version-bumps.sh` -> PR_STATIC_CONTROL_PATHS. Invoked only by
  `platform-and-compat.yml`, behind that workflow's own `has_direct_wasm_abi_risk`
  filter (which already names the script). No `Tests (Reborn)` lane runs it.
- `scripts/run-reborn-webui.sh` -> PR_STATIC_CONTROL_PATHS. A local developer
  launcher referenced by no workflow at all, so no lane can be selected for it.
- `scripts/reborn_qa_matrix/` -> QA_HARNESS_PREFIXES, beside `live-canary/` and
  `reborn_webui_v2_live_qa/`. Offline QA tooling over the route descriptors.

The fail-closed arm is untouched: an undecided repo-root script still refuses,
pinned by the existing second half of
`test_decided_repo_root_script_paths_are_owned_by_other_workflows`.

Regression tests: the two existing classification tests are extended to cover
all four paths. Sabotage-verified by removing the classifications and observing
4 errors (`ERROR: ... (path='scripts/check-version-bumps.sh')` and the three
siblings), then restoring -> 45 tests OK. The planner also now runs clean over
this PR's exact 45-path changed set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS10: name the new gates so the Code Style lane actually runs them

`code_style.yml`'s architecture step is `cargo test -p ironclaw_architecture
reborn` — a NAME filter, not a binary filter. None of the twelve new test
functions matched it, so all twelve of this PR's guardrails were invisible in
that lane: green, and checking nothing there.

`cargo test -p ironclaw_architecture reborn -- --list` counted 45 before this
change and 57 after, with every new gate now named:

    reborn_crate_inventory_measures_the_real_tree
    reborn_rust_and_python_crate_inventories_agree
    reborn_logical_spellings_resolve_to_each_crates_real_directory
    reborn_resolution_is_the_identity_on_a_flat_fixture_tree
    reborn_crate_moved_into_a_family_directory_still_resolves
    reborn_crate_that_no_longer_exists_is_refused_not_answered
    reborn_ambiguous_crate_name_is_refused_not_picked
    reborn_truncated_tree_refuses_rather_than_reporting_an_empty_inventory
    reborn_separate_workspaces_nested_manifests_and_build_output_are_excluded
    reborn_allowlist_entries_follow_a_crate_into_its_family_directory
    reborn_build_scripts_do_not_derive_the_repo_root_by_counted_parent_hops
    reborn_fixed_depth_matcher_catches_the_banned_shapes_and_ignores_prose

Rename only; no assertion changed. Full suite still 219 passed / 0 failed,
fmt clean, clippy zero warnings.

Note for the WS10 "guardrails must fail loudly on their own regressions" row:
that filter means Code Style runs 57 of the crate's 219 architecture tests. The
`Tests (Reborn)` bucket lane runs the crate unfiltered (`cargo test -p <pkg>
--all-targets`), so nothing is unrun overall — but a gate whose name misses
`reborn` is absent from the lane most reviewers read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(ws10): record the two gate defects this PR's own CI surfaced

The row's amendment listed four defects found while converting. Two more turned
up afterwards, from the PR's own CI run, and belong on the same row because
both are the fail-closed-with-no-rule / guardrail-that-checks-nothing shape it
already documents twice:

- `reborn_pr_test_plan.py` had no rule for four repo-root `scripts/` files the
  conversion touched, failing `Detect Reborn test scope` outright and skipping
  every downstream Reborn lane.
- `code_style.yml`'s architecture step filters on the test NAME `reborn`, so the
  twelve new gates were absent from it (45 -> 57 listed after the rename), and
  the lane as a whole runs 57 of the crate's 219 architecture tests.

Docs-only; the code changes both landed in earlier commits on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* WS5: sever conversations -> turns by port inversion; register 4 -> 3

ironclaw_conversations drops ironclaw_turns from [dependencies] and declares
the one coordinator call its inbound orchestration makes as a port. Zero
production behaviour moved: the orchestration, the trusted-trigger submitter
and every one of their tests stay in the crate that owned them.

The port (src/turn_submission.rs): ConversationTurnSubmitter, one method
submit_conversation_turn; ConversationTurnSubmission carrying only
host_api::turn vocabulary plus ConversationInboundClassification, the trust
value the orchestration derives from its own binding policy and never from the
adapter string; TurnSubmissionError with retry() and category()/
adapter_status_code() over the host's verbatim rendered cause.

The adapter (composition, automation/conversation_turn_submitter.rs, +158 net
production lines): holds the TurnCoordinator handle composition already
constructed for the trigger poller, calls product_context::resolve_inbound, and
maps TurnError -> port error totally (no wildcard arm).

CORRECTION to the pre-build analysis: the retry class is NOT derivable from the
category. The Conflict category straddles retryable TurnError::Conflict and
permanent LeaseMismatch/InvalidTransition/RunNotRetryable, so the port error
carries two independent axes, not one three-valued one. Same branches, same
ordering, same user-visible messages at every effect.

Invariants amended in the same diff, not silently contradicted: both
ironclaw_conversations/AGENTS.md and CLAUDE.md now name the port error and its
class partition where they named ironclaw_turns::TurnError, and both gained the
standing rule that a TurnCoordinator handle or an ironclaw_turns normal
dependency must not come back.

untrusted_trigger_adapter_records_product_inbound_not_scheduled_trigger is
byte-identical (verified) and still in inbound.rs. It asserts on the
SubmitTurnRequest a coordinator receives, so the fakes swapped to the port and
gained a documented mirror of the production adapter; ironclaw_turns is
retained as a DEV-dependency for that, with the reason in the manifest.
Dev-deps are not layer-matrix edges (is_normal_dependency filters them), and
cargo metadata confirms kind = dev with normal deps exactly
{extension_contracts, filesystem, host_api, safety, triggers} -- PROPOSAL
6.4.2's Deps clause, literally.

New seam coverage at the real adapter:
conversation_turn_submitter_maps_every_turn_error_to_its_class (16 rows: all 12
TurnError variants, AdmissionRejected once per reason; asserts category, retry,
that the port status equals the kernel's, and that the cause is verbatim);
conversation_turn_submitter_covers_every_turn_error_variant (discriminant
census); conversation_turn_submitter_mints_scheduled_trigger_only_for_trusted_trigger
(the composition half of the spoof guard). Composition's five
classify_materializer_inbound_error submission tests now build inputs through
the production mapping instead of a stand-in.

One consumer arm changed shape and is provably unreachable: ironclaw_product's
map_conversation_error only ever sees ConversationBindingService failures, which
never submit a turn (product has its own DefaultInboundTurnService). It now
yields TurnSubmissionRejected carrying the port error's rendering rather than
fabricating a TurnError to satisfy a variant no caller can reach. Recorded in
the CHECKLIST row rather than hidden.

Register: the conversations -> turns entry is deleted and
WS0_LAYER_MATRIX_EXCEPTION_BASELINE lowered 4 -> 3. No other entry touched.
Docs in the same diff: CHECKLIST WS5 row ticked with the as-built shape, WS1's
"count <= 12" verify row ticked (its enumerated clause is now fully true -- no
*->turns exception remains), PROPOSAL 6.4.2 amended with the built shape.
docs/plans/composition-pubuse.snapshot 131 -> 132 for the one deliberate
export, the module-owned adapter factory the integration harness uses instead
of hand-mirroring the wiring.

Verification (all unfiltered, none piped through head/tail):
  cargo fmt --all                                        clean
  clippy (6 crates, --all-targets --all-features -Dwarn) zero warnings
  cargo test -p ironclaw_conversations                   99 passed / 0 failed
  cargo test -p ironclaw_product                       1050 passed / 0 failed
  cargo test -p ironclaw_reborn_composition             945 passed / 0 failed
  cargo test -p ironclaw_architecture                    207 passed / 0 failed
  cargo test --test reborn_group_triggers                 15 passed / 0 failed
  cargo test --test reborn_group_journeys                 16 passed / 0 failed
  cargo check --workspace --all-targets                  clean (one
    pre-existing dead_code warning, unused_fetch_context in
    extension_support/src/skills.rs:572, confirmed on the base via git stash)
Register reads 3 entries against baseline 3; the ratchet and the staleness
check both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ci): exempt the consolidation's internal-move re-attributions that failed changed-coverage

The full-mode PR run failed the changed-line gate two ways: 74.74% vs
the 90% floor (1,080 misses — 1,065 of them the capabilities host.rs
six-workflow split, the obligations three-owner split, and the
first-party-tools move re-attributed as new code) and the generated
wasm bindings.rs tripping the empty-denominator fail-closed rule on its
single changed line (the wit path arg). Same-run proof of no real
loss: the global floor and every configured per-crate floor PASSED in
the failing run. Exact-line exemptions per manifest policy (#6963
class); the 15 uncovered lines in other crates stay measured.
Offline arithmetic on the gate's own numbers: 3,195/3,210 = 99.53%
post-exemption. Validated with --validate-manifest-only (191 entries).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(arch): reconcile the same-layer inventory and downgrade pins with the batch's re-layers

The #7156 gates met the batch's real movement and demanded the full
delta: ironclaw_sandbox's layer-origin row; five new same-layer edges
(four kernel edges made same-layer by the processes re-layer, one
substrates edge by the skills re-layer) with the baseline raised
70->75 then banked back to 72 as three stale skills edges deleted;
the skills DowngradePin freezing its six consumers at the move; and
two stale rows (deleted crates' origins, mcp's dead extensions
consumer entry). Every finding a real batch effect, none suppressed.
Composition absolute ceiling re-seeded to the batch tree's measured
45127 with the test record moved in lockstep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(batch): green-up — clippy doc-gap fix, enum-body classifier extension, declaration-edit exemptions

Three fixes from the batch's full-mode run and its queue post-mortem:
(1) the empty_line_after_doc_comments error my merge-resolution script
composed into the specificity recount doc (clippy now clean on the
arch crate, --all-targets --all-features);
(2) reborn_changed_coverage.py's mechanically_uninstrumentable_lines
learns enum bodies (variants incl. struct-shaped, where-claused
headers) — the single-unclassified-line class its own comments document
for inner attributes; fixtures proven red (2 failures) without the fix
and green with it;
(3) exact-line exemptions for the three declaration-only files the
empty-denominator rule caught (dedup-checked against the existing
entries; validator green at 194). With coverage now push-only (#7173)
these keep the MAIN enforcement lane green after this batch merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(batch): delete the never-wired no-egress test fixture that reds workspace clippy

unused_fetch_context was authored inside this batch (it does not exist
on main) for two input-shape tests that were never written — its doc
says 'both cases below' and it is the last item in its module. Zero
callers anywhere; -D warnings on the workspace clippy lanes (the exact
queue invocation) correctly rejected it, and three agents each measured
it 'pre-existing on my base' without any base owning it. The intended
tests (URL-install arms decided from input shape must not reach the
network) remain a good idea and are noted on the WS3 follow-up ledger
rather than blocking the batch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:33:22 +00:00

27 lines
990 B
Bash
Executable File

#!/usr/bin/env bash
# Print the repo-relative directory of the crate named $1, resolved through the
# shared inventory (scripts/ci/lib/crate_tree.py) rather than assuming the flat
# `crates/<name>/` shape the family move (PROPOSAL §5) removes. Exits non-zero
# and explains when the name does not resolve — a caller must never silently
# `cd` to a path that is not there.
#
# Usage:
# scripts/ci/crate-dir.sh <crate-name> [repo-root]
#
# repo-root defaults to `git rev-parse --show-toplevel`; pass it explicitly
# from a fixture or a checkout that is not the caller's own working directory
# (e.g. a second checkout in CI, or a self-test fixture repo).
set -euo pipefail
if [ "$#" -lt 1 ]; then
echo "usage: crate-dir.sh <crate-name> [repo-root]" >&2
exit 2
fi
crate_name="$1"
repo_root="${2:-$(git rev-parse --show-toplevel)}"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "${script_dir}/lib/crate_tree.py" --directory "${crate_name}" "${repo_root}"